diff --git a/lib/core/storage/activity_log_repository.dart b/lib/core/storage/activity_log_repository.dart new file mode 100644 index 00000000..5f12c766 --- /dev/null +++ b/lib/core/storage/activity_log_repository.dart @@ -0,0 +1,105 @@ +import 'package:flutter/foundation.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; + +/// The local audit trails: executed data / schema changes and MCP tool calls. +/// Part of [LocalDb], which keeps the same methods and delegates here. +class ActivityLogRepository { + ActivityLogRepository(this._open); + + final Future Function() _open; + + var _mutationAuditInsertCount = 0; + + /// Appends one executed mutation to the audit trail and trims the table to + /// [kMutationAuditCap] rows. Never throws: auditing must not break a query. + Future recordMutationAudit({ + int? connectionId, + required String connectionName, + String? environment, + String? databaseName, + required String sqlText, + int? rowsAffected, + required MutationAuditSource source, + }) async { + final sql = sqlText.trim(); + if (sql.isEmpty) return; + try { + final db = await _open(); + await db.insert('mutation_audit', { + 'connection_id': connectionId, + 'connection_name': connectionName, + 'environment': environment, + 'database_name': normalizeHistoryDatabaseName(databaseName), + 'sql_text': sql, + 'rows_affected': rowsAffected, + 'source': source.storageValue, + 'recorded_at': DateTime.now().toUtc().toIso8601String(), + }); + _mutationAuditInsertCount++; + if (_mutationAuditInsertCount >= 50) { + _mutationAuditInsertCount = 0; + await db.rawDelete( + 'DELETE FROM mutation_audit WHERE id <= (' + 'SELECT id FROM mutation_audit ORDER BY id DESC LIMIT 1 OFFSET ?)', + [kMutationAuditCap], + ); + } + } catch (e) { + // Best effort, but a missing audit trail should not be invisible. + debugPrint('LocalDb: could not record a mutation audit entry: $e'); + } + } + + /// Newest-first audit entries, optionally for one connection. + Future> listMutationAudit({ + int? connectionId, + int limit = 200, + }) async { + final db = await _open(); + final rows = await db.query( + 'mutation_audit', + where: connectionId == null ? null : 'connection_id = ?', + whereArgs: connectionId == null ? null : [connectionId], + orderBy: 'id DESC', + limit: limit, + ); + return rows.map(MutationAuditEntry.fromMap).toList(); + } + + Future clearMutationAudit() async { + final db = await _open(); + await db.delete('mutation_audit'); + _mutationAuditInsertCount = 0; + } + + /// Appends one MCP tool call and keeps the newest [kMcpActivityCap]. + /// Never throws: logging must not break a tool call. + Future recordMcpActivity(McpActivityEntry entry) async { + try { + final db = await _open(); + final map = entry.toMap()..remove('id'); + await db.insert('mcp_activity', map); + await db.rawDelete( + 'DELETE FROM mcp_activity WHERE id <= (' + 'SELECT id FROM mcp_activity ORDER BY id DESC LIMIT 1 OFFSET ?)', + [kMcpActivityCap], + ); + } catch (e) { + debugPrint('LocalDb: could not record MCP activity: $e'); + } + } + + /// Newest-first MCP tool calls. + Future> listMcpActivity({int limit = 200}) async { + final db = await _open(); + final rows = + await db.query('mcp_activity', orderBy: 'id DESC', limit: limit); + return rows.map(McpActivityEntry.fromMap).toList(); + } + + Future clearMcpActivity() async { + final db = await _open(); + await db.delete('mcp_activity'); + } +} diff --git a/lib/core/storage/app_settings_repository.dart b/lib/core/storage/app_settings_repository.dart new file mode 100644 index 00000000..f248f4ab --- /dev/null +++ b/lib/core/storage/app_settings_repository.dart @@ -0,0 +1,60 @@ +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Key-value app settings and the saved ER diagram layouts. +/// Part of [LocalDb], which keeps the same methods and delegates here. +class AppSettingsRepository { + AppSettingsRepository(this._open); + + final Future Function() _open; + + Future getAppSetting(String key) async { + final db = await _open(); + final rows = await db.query( + 'app_settings', + columns: ['value'], + where: 'key = ?', + whereArgs: [key], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['value'] as String?; + } + + Future setAppSetting(String key, String value) async { + final db = await _open(); + await db.rawInsert( + 'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', + [key, value], + ); + } + + Future deleteAppSetting(String key) async { + final db = await _open(); + await db.delete('app_settings', where: 'key = ?', whereArgs: [key]); + } + + Future readErdLayout(int connectionId, String scope) async { + final db = await _open(); + final rows = await db.query( + 'erd_layouts', + columns: ['payload'], + where: 'connection_id = ? AND scope = ?', + whereArgs: [connectionId, scope], + limit: 1, + ); + return rows.isEmpty ? null : rows.first['payload'] as String?; + } + + Future writeErdLayout( + int connectionId, + String scope, + String payload, + ) async { + final db = await _open(); + await db.rawInsert( + 'INSERT OR REPLACE INTO erd_layouts ' + '(connection_id, scope, payload, updated_at) VALUES (?, ?, ?, ?)', + [connectionId, scope, payload, DateTime.now().toUtc().toIso8601String()], + ); + } +} diff --git a/lib/core/storage/connection_repository.dart b/lib/core/storage/connection_repository.dart new file mode 100644 index 00000000..6d49ead5 --- /dev/null +++ b/lib/core/storage/connection_repository.dart @@ -0,0 +1,196 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; + +/// Saved connections: rows, secrets and SSH configuration kept in step. +/// Part of [LocalDb], which keeps the same methods and delegates here. +class ConnectionRepository { + ConnectionRepository(this._open, {this.onRemoved}); + + final Future Function() _open; + + /// Told the id of each connection removed, for state kept elsewhere. + final void Function(int id)? onRemoved; + + /// Returns all saved connections. + /// + /// By default, passwords and connection strings are NOT eagerly hydrated from + /// the platform secure store to avoid IPC bottlenecks, Keychain lockups, and + /// D-Bus timeouts during sidebar/startup population. Secrets are resolved + /// on-demand when a connection is initiated. + Future> getConnections( + {bool hydrateSecrets = false}) async { + final db = await _open(); + final rows = + await db.query('connections', orderBy: 'sort_order ASC, name ASC'); + if (!hydrateSecrets) { + return rows.map((m) => ConnectionRow.fromMap(m)).toList(); + } + final futures = + rows.map((m) => _hydrateConnection(ConnectionRow.fromMap(m))); + return Future.wait(futures); + } + + static Future _hydrateConnection(ConnectionRow row) async { + if (row.id == null) return row; + try { + final secrets = await ConnectionSecretsStore.readForConnection(row.id!); + return row.copyWith( + password: secrets.password ?? row.password, + connectionString: secrets.connectionString ?? row.connectionString, + ); + } catch (e) { + debugPrint( + 'LocalDb._hydrateConnection failed for connection ${row.id}: $e'); + return row; + } + } + + /// Hydrates secrets for a single [ConnectionRow] on demand. + Future hydrateConnection(ConnectionRow row) => + _hydrateConnection(row); + + /// Retrieves a single connection by [id], optionally hydrating secrets. + Future getConnectionById(int id, + {bool hydrateSecrets = false}) async { + final db = await _open(); + final rows = + await db.query('connections', where: 'id = ?', whereArgs: [id]); + if (rows.isEmpty) return null; + final row = ConnectionRow.fromMap(rows.first); + if (!hydrateSecrets) return row; + return _hydrateConnection(row); + } + + /// Inserts a row and returns the SQLite row id. + /// Password and connection string are stored in the OS secure store, not in SQLite. + /// + /// If writing secrets fails, the SQLite row is rolled back and the error is + /// rethrown so callers can surface a Keychain / libsecret failure. + Future addConnection(ConnectionRow row) async { + final db = await _open(); + final id = await db.insert('connections', row.toPersistenceMap()); + try { + await ConnectionSecretsStore.writeForConnection( + id, + password: row.password, + connectionString: row.connectionString, + ); + if (row.sshSecrets != null) { + await ConnectionSecretsStore.writeSshSecretsForConnection( + id, + password: row.sshSecrets!.password, + privateKey: row.sshSecrets!.privateKey, + passphrase: row.sshSecrets!.passphrase, + jumpPassword: row.sshSecrets!.jumpPassword, + ); + } + } catch (e) { + try { + await ConnectionSecretsStore.deleteForConnection(id); + } catch (_) { + // Best-effort cleanup of any partial secret writes. + } + await db.delete('connections', where: 'id = ?', whereArgs: [id]); + rethrow; + } + return id; + } + + /// Updates an existing connection row in SQLite and its secrets in the secure store. + /// + /// If writing secrets fails, the previous SQLite row and previous secrets are + /// restored (best effort) and the error is rethrown. + Future updateConnection(ConnectionRow row) async { + if (row.id == null) { + throw ArgumentError( + 'ConnectionRow.id cannot be null when calling updateConnection'); + } + final db = await _open(); + final previousMaps = await db.query( + 'connections', + where: 'id = ?', + whereArgs: [row.id], + ); + if (previousMaps.isEmpty) { + throw ArgumentError('No connection found with id ${row.id}'); + } + final previousRow = ConnectionRow.fromMap(previousMaps.first); + final previousSecrets = + await ConnectionSecretsStore.readForConnection(row.id!); + final previousSsh = + await ConnectionSecretsStore.readSshSecretsForConnection(row.id!); + + await db.transaction((txn) async { + final count = await txn.update( + 'connections', + row.toPersistenceMap(), + where: 'id = ?', + whereArgs: [row.id], + ); + if (count == 0) { + throw ArgumentError('No connection found with id ${row.id}'); + } + }); + + try { + await ConnectionSecretsStore.writeForConnection( + row.id!, + password: row.password, + connectionString: row.connectionString, + ); + if (row.sshSecrets != null) { + await ConnectionSecretsStore.writeSshSecretsForConnection( + row.id!, + password: row.sshSecrets!.password, + privateKey: row.sshSecrets!.privateKey, + passphrase: row.sshSecrets!.passphrase, + jumpPassword: row.sshSecrets!.jumpPassword, + ); + } else if (row.sshTunnelConfig?.enabled != true) { + // The tunnel was turned off: its password and key do not stay in the + // keyring (#1311). + await ConnectionSecretsStore.writeSshSecretsForConnection(row.id!); + } + } catch (e) { + await db.update( + 'connections', + previousRow.toPersistenceMap(), + where: 'id = ?', + whereArgs: [row.id], + ); + try { + await ConnectionSecretsStore.writeForConnection( + row.id!, + password: previousSecrets.password, + connectionString: previousSecrets.connectionString, + ); + // The SSH secrets may be half written too (four keys, one by one). + await ConnectionSecretsStore.writeSshSecretsForConnection( + row.id!, + password: previousSsh.password, + privateKey: previousSsh.privateKey, + passphrase: previousSsh.passphrase, + jumpPassword: previousSsh.jumpPassword, + ); + } catch (_) { + // Best-effort restore of previous secrets; surface the original error. + } + rethrow; + } + } + + /// Deletes a connection. SQLite deletion always proceeds even if the secure + /// store delete fails (e.g. missing key or unavailable libsecret daemon). + Future removeConnection(int id) async { + onRemoved?.call(id); + try { + await ConnectionSecretsStore.deleteForConnection(id); + } catch (_) { + // Do not block removing the connection metadata when the OS store fails. + } + final db = await _open(); + await db.delete('connections', where: 'id = ?', whereArgs: [id]); + } +} diff --git a/lib/core/storage/folder_repository.dart b/lib/core/storage/folder_repository.dart new file mode 100644 index 00000000..96afaad0 --- /dev/null +++ b/lib/core/storage/folder_repository.dart @@ -0,0 +1,65 @@ +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; + +/// Folders: names, order, and which connection sits in which. +/// Part of [LocalDb], which keeps the same methods and delegates here. +class FolderRepository { + FolderRepository(this._open); + + final Future Function() _open; + + Future> getFolders() async { + final db = await _open(); + final rows = await db.query('folders', orderBy: 'sort_order ASC, name ASC'); + return rows.map((r) => r['name'] as String).toList(); + } + + Future addFolder(String name) async { + final n = name.trim(); + if (n.isEmpty) return; + final db = await _open(); + await db.insert('folders', {'name': n}); + } + + /// Removes the folder; its connections stay, without a folder. + /// + /// `connections.folder_id` is `ON DELETE CASCADE`, which would delete them + /// with it. They are detached first, so the cascade finds nothing: no schema + /// migration of the connections table is needed. + Future removeFolder(String name) async { + final db = await _open(); + await db.transaction((txn) async { + await txn.rawUpdate( + 'UPDATE connections SET folder_id = NULL WHERE folder_id IN ' + '(SELECT id FROM folders WHERE name = ?)', + [name], + ); + await txn.delete('folders', where: 'name = ?', whereArgs: [name]); + }); + } + + /// Moves a connection into the folder [folderId], or out of any folder when + /// it is null. Only the folder changes: secrets and settings are untouched. + Future setConnectionFolder(int connectionId, int? folderId) async { + final db = await _open(); + await db.update( + 'connections', + {'folder_id': folderId}, + where: 'id = ?', + whereArgs: [connectionId], + ); + } + + Future clearFolders() async { + final db = await _open(); + await db.delete('folders'); + } + + Future getFolderIdByName(String name) async { + final db = await _open(); + final rows = await db.query('folders', + columns: ['id'], where: 'name = ?', whereArgs: [name]); + if (rows.isEmpty) return null; + return sqliteInt(rows.first['id']); + } +} diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 516e5590..323b477e 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -1,46 +1,22 @@ -import 'dart:convert'; import 'dart:io'; -import 'dart:math'; -import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; -import 'package:querya_desktop/core/security/connection_environment.dart'; -import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/storage/app_data_root.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/storage/folder_repository.dart'; +import 'package:querya_desktop/core/storage/sql_query_history_repository.dart'; +import 'package:querya_desktop/core/storage/activity_log_repository.dart'; +import 'package:querya_desktop/core/storage/app_settings_repository.dart'; +import 'package:querya_desktop/core/storage/connection_repository.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; +import 'package:querya_desktop/core/storage/local_db_schema.dart'; + +export 'package:querya_desktop/core/storage/local_db_models.dart'; import 'package:querya_desktop/core/storage/update_backup.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:querya_desktop/core/util/log_ignored.dart'; const _dbName = 'querya.db'; -const _dbVersion = 13; - -/// `app_settings` key under which each profile database's random id is -/// stored (see [LocalDb._ensureProfileId] and issue #986). -const _profileIdSettingKey = 'profile_id'; - -String _generateProfileId() { - final rand = Random.secure(); - final bytes = List.generate(16, (_) => rand.nextInt(256)); - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); -} - -/// Fallback when [recordSqlQueryHistory] is called without `maxEntries`. -/// Keep in sync with [kDefaultSqlHistoryMaxEntries] in `app_settings.dart`. -const int kDefaultSqlHistoryCap = 100; - -int? _sqliteInt(Object? v) { - if (v == null) return null; - if (v is int) return v; - if (v is num) return v.toInt(); - return int.tryParse(v.toString()); -} - -String? _normalizeHistoryDatabaseName(String? databaseName) { - final t = databaseName?.trim(); - if (t == null || t.isEmpty) return null; - return t; -} /// The local database was written by a newer Querya Desktop: its schema /// version is above the one this build knows (#1497). @@ -113,22 +89,18 @@ class LocalDb { final db = await databaseFactoryFfi.openDatabase( _cachedDbPath!, options: OpenDatabaseOptions( - version: _dbVersion, - onCreate: _onCreate, - onUpgrade: _onUpgrade, + version: LocalDbSchema.version, + onCreate: LocalDbSchema.onCreate, + onUpgrade: LocalDbSchema.onUpgrade, // A newer schema: refuse, so sqflite does not stamp the old version // on it (the open transaction rolls back). - onDowngrade: (db, found, supported) => throw LocalDbNewerSchemaException( + onDowngrade: (db, found, supported) => + throw LocalDbNewerSchemaException( found: found, supported: supported, path: _cachedDbPath!, ), - onOpen: (db) async { - await db.execute('PRAGMA foreign_keys = ON'); - await db.execute('PRAGMA journal_mode = WAL'); - await db.execute('PRAGMA busy_timeout = 5000'); - await db.execute('PRAGMA synchronous = NORMAL'); - }, + onOpen: LocalDbSchema.onOpen, ), ); _db = db; @@ -136,327 +108,35 @@ class LocalDb { // Safety net for the common case (db already at the current version, so // neither onCreate nor onUpgrade ran this open): _onCreate/_onUpgrade // already set this when they do run, ahead of any secret read/write. - ConnectionSecretsStore.profileId ??= await _ensureProfileId(db); + ConnectionSecretsStore.profileId ??= + await LocalDbSchema.ensureProfileId(db); return db; } - /// Returns this profile database's random id (see issue #986), generating - /// and persisting one on first use. Namespaces [ConnectionSecretsStore] - /// keys so two profile databases never collide in the shared OS keyring. - Future _ensureProfileId(Database db) async { - final rows = await db.query( - 'app_settings', - columns: ['value'], - where: 'key = ?', - whereArgs: [_profileIdSettingKey], - limit: 1, - ); - final existing = rows.isNotEmpty ? rows.first['value'] as String? : null; - if (existing != null && existing.isNotEmpty) return existing; - final id = _generateProfileId(); - await db.rawInsert( - 'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', - [_profileIdSettingKey, id], - ); - return id; - } - - /// Queries an active PRAGMA setting from the database for verification and diagnostic purposes. - Future getPragma(String pragmaName) async { - final db = await _open(); - final res = await db.rawQuery('PRAGMA $pragmaName'); - if (res.isNotEmpty && res.first.values.isNotEmpty) { - return res.first.values.first.toString(); - } - return ''; - } - - Future _onCreate(Database db, int version) async { - await db.execute(''' - CREATE TABLE folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - sort_order INTEGER NOT NULL DEFAULT 0 - ) - '''); - await db.execute(''' - CREATE TABLE connections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - name TEXT NOT NULL, - host TEXT, - port INTEGER, - username TEXT, - password TEXT, - database_name TEXT, - auth_source TEXT, - use_ssl INTEGER NOT NULL DEFAULT 0, - connection_string TEXT, - extension_id TEXT, - driver_options TEXT, - folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL - ) - '''); - await db.execute(''' - CREATE TABLE app_settings ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT NOT NULL - ) - '''); - await db.execute(''' - CREATE TABLE sql_query_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, - database_name TEXT, - sql_text TEXT NOT NULL, - recorded_at TEXT NOT NULL - ) - '''); - await db.execute(''' - CREATE INDEX idx_sql_query_history_lookup - ON sql_query_history (connection_id, database_name, recorded_at DESC, id DESC) - '''); - await _createMutationAuditTable(db); - await _createMcpActivityTable(db); - await _createErdLayoutsTable(db); - ConnectionSecretsStore.profileId = await _ensureProfileId(db); - } - - /// Local audit trail of executed data / schema changes (#1062). Not linked - /// to `connections` so entries outlive a deleted connection; the connection - /// name is stored as a snapshot. - Future _createMutationAuditTable(Database db) async { - await db.execute(''' - CREATE TABLE IF NOT EXISTS mutation_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - connection_id INTEGER, - connection_name TEXT NOT NULL, - environment TEXT, - database_name TEXT, - sql_text TEXT NOT NULL, - rows_affected INTEGER, - source TEXT NOT NULL, - recorded_at TEXT NOT NULL - ) - '''); - await db.execute(''' - CREATE INDEX IF NOT EXISTS idx_mutation_audit_recorded - ON mutation_audit (id DESC) - '''); - } - - /// Calls made by MCP clients (#1136), newest kept, capped at - /// [kMcpActivityCap]. Connection name is a snapshot, like the audit. - Future _createMcpActivityTable(Database db) async { - await db.execute(''' - CREATE TABLE IF NOT EXISTS mcp_activity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at TEXT NOT NULL, - client TEXT NOT NULL, - tool TEXT NOT NULL, - connection_id INTEGER, - connection_name TEXT, - sql_text TEXT, - row_count INTEGER, - duration_ms INTEGER NOT NULL, - error TEXT, - refusal_rule TEXT - ) - '''); - } - - Future _onUpgrade(Database db, int oldVersion, int newVersion) async { - // Every upgrade path needs app_settings before it can read/write the - // profile id; the oldVersion < 4 block below also creates this table for - // installs that predate it, but IF NOT EXISTS keeps this idempotent. - await db.execute(''' - CREATE TABLE IF NOT EXISTS app_settings ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT NOT NULL - ) - '''); - ConnectionSecretsStore.profileId = await _ensureProfileId(db); - - if (oldVersion < 2) { - await db.execute('ALTER TABLE connections ADD COLUMN password TEXT'); - await db.execute('ALTER TABLE connections ADD COLUMN database_name TEXT'); - await db.execute('ALTER TABLE connections ADD COLUMN auth_source TEXT'); - await db.execute( - 'ALTER TABLE connections ADD COLUMN use_ssl INTEGER NOT NULL DEFAULT 0'); - await db - .execute('ALTER TABLE connections ADD COLUMN connection_string TEXT'); - } - if (oldVersion < 3) { - await db.execute('PRAGMA foreign_keys=ON'); - await db.execute(''' - CREATE TABLE connections_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - name TEXT NOT NULL, - host TEXT, - port INTEGER, - username TEXT, - password TEXT, - database_name TEXT, - auth_source TEXT, - use_ssl INTEGER NOT NULL DEFAULT 0, - connection_string TEXT, - folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL - ) - '''); - await db.execute(''' - INSERT INTO connections_new - SELECT id, type, name, host, port, username, password, database_name, - auth_source, use_ssl, connection_string, folder_id, sort_order, created_at - FROM connections - '''); - await db.execute('DROP TABLE connections'); - await db.execute('ALTER TABLE connections_new RENAME TO connections'); - } - // (oldVersion < 4 used to CREATE TABLE app_settings here; the - // CREATE TABLE IF NOT EXISTS above now covers that install path too.) - if (oldVersion < 5) { - final rows = await db.query('connections'); - for (final m in rows) { - final id = _sqliteInt(m['id']); - if (id == null) continue; - final pwd = m['password'] as String?; - final cs = m['connection_string'] as String?; - await ConnectionSecretsStore.writeForConnection( - id, - password: (pwd != null && pwd.isNotEmpty) ? pwd : null, - connectionString: (cs != null && cs.isNotEmpty) ? cs : null, - ); - } - await db.execute( - 'UPDATE connections SET password = NULL, connection_string = NULL', - ); - } - if (oldVersion < 6) { - await db.execute(''' - CREATE TABLE sql_query_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, - database_name TEXT, - sql_text TEXT NOT NULL, - recorded_at TEXT NOT NULL - ) - '''); - await db.execute(''' - CREATE INDEX idx_sql_query_history_lookup - ON sql_query_history (connection_id, recorded_at DESC) - '''); - } - if (oldVersion < 7) { - await db.execute('ALTER TABLE connections ADD COLUMN extension_id TEXT'); - await db - .execute('ALTER TABLE connections ADD COLUMN driver_options TEXT'); - } - if (oldVersion < 8) { - await db.execute('DROP INDEX IF EXISTS idx_sql_query_history_lookup'); - await db.execute(''' - CREATE INDEX idx_sql_query_history_lookup - ON sql_query_history (connection_id, database_name, recorded_at DESC, id DESC) - '''); - } - if (oldVersion < 9) { - // Adopt this profile's own pre-#986 unnamespaced keyring secrets (if - // any) under the namespaced key generated above, so this database's - // connections keep working after another profile with an overlapping - // connection id also upgrades. - final rows = await db.query('connections', columns: ['id']); - for (final m in rows) { - final id = _sqliteInt(m['id']); - if (id == null) continue; - await ConnectionSecretsStore.adoptLegacyKeysForConnection(id); - } - } - if (oldVersion < 10) { - await _createMutationAuditTable(db); - } - if (oldVersion < 11) { - await _createMcpActivityTable(db); - } - if (oldVersion >= 11 && oldVersion < 12) { - // The rule that refused a call, next to its error text. - await db.execute('ALTER TABLE mcp_activity ADD COLUMN refusal_rule TEXT'); - } - if (oldVersion < 13) { - await _createErdLayoutsTable(db); - } - } - - /// What the user arranged on an ER diagram (#1275), one row per connection - /// and scope (database / schema), removed with its connection. - Future _createErdLayoutsTable(Database db) async { - await db.execute(''' - CREATE TABLE IF NOT EXISTS erd_layouts ( - connection_id INTEGER NOT NULL - REFERENCES connections(id) ON DELETE CASCADE, - scope TEXT NOT NULL, - payload TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (connection_id, scope) - ) - '''); - } + // One repository per area; the methods below delegate to them. + late final FolderRepository folders = FolderRepository(_open); + late final SqlQueryHistoryRepository sqlHistory = + SqlQueryHistoryRepository(_open); + late final ActivityLogRepository activity = ActivityLogRepository(_open); + late final AppSettingsRepository settings = AppSettingsRepository(_open); + late final ConnectionRepository connections = ConnectionRepository( + _open, + onRemoved: sqlHistory.forgetConnection, + ); - Future readErdLayout(int connectionId, String scope) async { - final db = await _open(); - final rows = await db.query( - 'erd_layouts', - columns: ['payload'], - where: 'connection_id = ? AND scope = ?', - whereArgs: [connectionId, scope], - limit: 1, - ); - return rows.isEmpty ? null : rows.first['payload'] as String?; - } + Future> getFolders() => folders.getFolders(); - Future writeErdLayout( - int connectionId, - String scope, - String payload, - ) async { - final db = await _open(); - await db.rawInsert( - 'INSERT OR REPLACE INTO erd_layouts ' - '(connection_id, scope, payload, updated_at) VALUES (?, ?, ?, ?)', - [connectionId, scope, payload, DateTime.now().toUtc().toIso8601String()], - ); - } + Future addFolder(String name) => folders.addFolder(name); - Future getAppSetting(String key) async { - final db = await _open(); - final rows = await db.query( - 'app_settings', - columns: ['value'], - where: 'key = ?', - whereArgs: [key], - limit: 1, - ); - if (rows.isEmpty) return null; - return rows.first['value'] as String?; - } + Future removeFolder(String name) => folders.removeFolder(name); - Future setAppSetting(String key, String value) async { - final db = await _open(); - await db.rawInsert( - 'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', - [key, value], - ); - } + Future setConnectionFolder(int connectionId, int? folderId) => + folders.setConnectionFolder(connectionId, folderId); - Future deleteAppSetting(String key) async { - final db = await _open(); - await db.delete('app_settings', where: 'key = ?', whereArgs: [key]); - } + Future clearFolders() => folders.clearFolders(); - final Map _historyInsertCounts = {}; + Future getFolderIdByName(String name) => + folders.getFolderIdByName(name); Future recordSqlQueryHistory({ required int connectionId, @@ -464,132 +144,32 @@ class LocalDb { required String sqlText, int maxEntries = kDefaultSqlHistoryCap, bool forcePrune = false, - }) async { - final sql = sqlText.trim(); - if (sql.isEmpty) return; - if (maxEntries < 1) return; - final db = await _open(); - final dbKey = _normalizeHistoryDatabaseName(databaseName); - final now = DateTime.now().toUtc().toIso8601String(); - await db.insert('sql_query_history', { - 'connection_id': connectionId, - 'database_name': dbKey, - 'sql_text': sql, - 'recorded_at': now, - }); - - final bucketKey = '$connectionId::${dbKey ?? ''}'; - final insertCount = (_historyInsertCounts[bucketKey] ?? 0) + 1; - _historyInsertCounts[bucketKey] = insertCount; - - final batchThreshold = maxEntries <= 10 ? 1 : 10; - if (forcePrune || insertCount >= batchThreshold) { - _historyInsertCounts[bucketKey] = 0; - await _pruneSqlQueryHistoryBucket( - db, - connectionId: connectionId, - databaseName: dbKey, - maxEntries: maxEntries, - ); - } - } - - /// Keeps the newest [maxEntries] rows in a (connection, database) bucket. - /// - /// Selects overflow ids (oldest first), then deletes by primary key — avoids - /// nested `DELETE … SELECT … OFFSET` plans as history grows. - Future _pruneSqlQueryHistoryBucket( - Database db, { - required int connectionId, - required String? databaseName, - required int maxEntries, - }) async { - final countRows = await db.rawQuery( - ''' - SELECT COUNT(*) AS c FROM sql_query_history - WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? - ''', - [connectionId, databaseName], - ); - final count = _sqliteInt(countRows.first['c']) ?? 0; - final excess = count - maxEntries; - if (excess <= 0) return; - - final overflow = await db.rawQuery( - ''' - SELECT id FROM sql_query_history - WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? - ORDER BY id ASC - LIMIT ? - ''', - [connectionId, databaseName, excess], - ); - if (overflow.isEmpty) return; - - final ids = []; - for (final row in overflow) { - final id = row['id']; - if (id != null) ids.add(id); - } - if (ids.isEmpty) return; - - final placeholders = List.filled(ids.length, '?').join(','); - await db.rawDelete( - 'DELETE FROM sql_query_history WHERE id IN ($placeholders)', - ids, - ); - } + }) => + sqlHistory.recordSqlQueryHistory( + connectionId: connectionId, + databaseName: databaseName, + sqlText: sqlText, + maxEntries: maxEntries, + forcePrune: forcePrune); Future> listSqlQueryHistory({ required int connectionId, String? databaseName, int limit = 50, - }) async { - final db = await _open(); - final dbKey = _normalizeHistoryDatabaseName(databaseName); - final rows = await db.rawQuery( - ''' - SELECT id, connection_id, database_name, sql_text, recorded_at - FROM sql_query_history - WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? - ORDER BY id DESC - LIMIT ? - ''', - [connectionId, dbKey, limit], - ); - return rows.map(SqlQueryHistoryEntry.fromMap).toList(); - } + }) => + sqlHistory.listSqlQueryHistory( + connectionId: connectionId, databaseName: databaseName, limit: limit); - /// Removes all history rows for [connectionId] (every database bucket). - Future clearSqlQueryHistoryForConnection(int connectionId) async { - _historyInsertCounts.removeWhere((k, _) => k.startsWith('$connectionId::')); - final db = await _open(); - await db.delete( - 'sql_query_history', - where: 'connection_id = ?', - whereArgs: [connectionId], - ); - } + Future clearSqlQueryHistoryForConnection(int connectionId) => + sqlHistory.clearSqlQueryHistoryForConnection(connectionId); - /// Removes history for [connectionId] and [databaseName] only (same bucket as [listSqlQueryHistory]). Future clearSqlQueryHistoryBucket({ required int connectionId, String? databaseName, - }) async { - final dbKey = _normalizeHistoryDatabaseName(databaseName); - _historyInsertCounts.remove('$connectionId::${dbKey ?? ''}'); - final db = await _open(); - await db.rawDelete( - ''' - DELETE FROM sql_query_history - WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? - ''', - [connectionId, dbKey], - ); - } + }) => + sqlHistory.clearSqlQueryHistoryBucket( + connectionId: connectionId, databaseName: databaseName); - /// Appends one executed mutation to the audit trail and trims the table to - /// [kMutationAuditCap] rows. Never throws: auditing must not break a query. Future recordMutationAudit({ int? connectionId, required String connectionName, @@ -598,324 +178,75 @@ class LocalDb { required String sqlText, int? rowsAffected, required MutationAuditSource source, - }) async { - final sql = sqlText.trim(); - if (sql.isEmpty) return; - try { - final db = await _open(); - await db.insert('mutation_audit', { - 'connection_id': connectionId, - 'connection_name': connectionName, - 'environment': environment, - 'database_name': _normalizeHistoryDatabaseName(databaseName), - 'sql_text': sql, - 'rows_affected': rowsAffected, - 'source': source.storageValue, - 'recorded_at': DateTime.now().toUtc().toIso8601String(), - }); - _mutationAuditInsertCount++; - if (_mutationAuditInsertCount >= 50) { - _mutationAuditInsertCount = 0; - await db.rawDelete( - 'DELETE FROM mutation_audit WHERE id <= (' - 'SELECT id FROM mutation_audit ORDER BY id DESC LIMIT 1 OFFSET ?)', - [kMutationAuditCap], - ); - } - } catch (e) { - // Best effort, but a missing audit trail should not be invisible. - debugPrint('LocalDb: could not record a mutation audit entry: $e'); - } - } - - var _mutationAuditInsertCount = 0; + }) => + activity.recordMutationAudit( + connectionId: connectionId, + connectionName: connectionName, + environment: environment, + databaseName: databaseName, + sqlText: sqlText, + rowsAffected: rowsAffected, + source: source); - /// Newest-first audit entries, optionally for one connection. Future> listMutationAudit({ int? connectionId, int limit = 200, - }) async { - final db = await _open(); - final rows = await db.query( - 'mutation_audit', - where: connectionId == null ? null : 'connection_id = ?', - whereArgs: connectionId == null ? null : [connectionId], - orderBy: 'id DESC', - limit: limit, - ); - return rows.map(MutationAuditEntry.fromMap).toList(); - } + }) => + activity.listMutationAudit(connectionId: connectionId, limit: limit); - Future clearMutationAudit() async { - final db = await _open(); - await db.delete('mutation_audit'); - _mutationAuditInsertCount = 0; - } - - /// Appends one MCP tool call and keeps the newest [kMcpActivityCap]. - /// Never throws: logging must not break a tool call. - Future recordMcpActivity(McpActivityEntry entry) async { - try { - final db = await _open(); - final map = entry.toMap()..remove('id'); - await db.insert('mcp_activity', map); - await db.rawDelete( - 'DELETE FROM mcp_activity WHERE id <= (' - 'SELECT id FROM mcp_activity ORDER BY id DESC LIMIT 1 OFFSET ?)', - [kMcpActivityCap], - ); - } catch (e) { - debugPrint('LocalDb: could not record MCP activity: $e'); - } - } - - /// Newest-first MCP tool calls. - Future> listMcpActivity({int limit = 200}) async { - final db = await _open(); - final rows = - await db.query('mcp_activity', orderBy: 'id DESC', limit: limit); - return rows.map(McpActivityEntry.fromMap).toList(); - } + Future clearMutationAudit() => activity.clearMutationAudit(); - Future clearMcpActivity() async { - final db = await _open(); - await db.delete('mcp_activity'); - } + Future recordMcpActivity(McpActivityEntry entry) => + activity.recordMcpActivity(entry); - Future> getFolders() async { - final db = await _open(); - final rows = await db.query('folders', orderBy: 'sort_order ASC, name ASC'); - return rows.map((r) => r['name'] as String).toList(); - } + Future> listMcpActivity({int limit = 200}) => + activity.listMcpActivity(limit: limit); - Future addFolder(String name) async { - final n = name.trim(); - if (n.isEmpty) return; - final db = await _open(); - await db.insert('folders', {'name': n}); - } + Future clearMcpActivity() => activity.clearMcpActivity(); - /// Removes the folder; its connections stay, without a folder. - /// - /// `connections.folder_id` is `ON DELETE CASCADE`, which would delete them - /// with it. They are detached first, so the cascade finds nothing: no schema - /// migration of the connections table is needed. - Future removeFolder(String name) async { - final db = await _open(); - await db.transaction((txn) async { - await txn.rawUpdate( - 'UPDATE connections SET folder_id = NULL WHERE folder_id IN ' - '(SELECT id FROM folders WHERE name = ?)', - [name], - ); - await txn.delete('folders', where: 'name = ?', whereArgs: [name]); - }); - } + Future getAppSetting(String key) => settings.getAppSetting(key); - /// Moves a connection into the folder [folderId], or out of any folder when - /// it is null. Only the folder changes: secrets and settings are untouched. - Future setConnectionFolder(int connectionId, int? folderId) async { - final db = await _open(); - await db.update( - 'connections', - {'folder_id': folderId}, - where: 'id = ?', - whereArgs: [connectionId], - ); - } + Future setAppSetting(String key, String value) => + settings.setAppSetting(key, value); - Future clearFolders() async { - final db = await _open(); - await db.delete('folders'); - } + Future deleteAppSetting(String key) => settings.deleteAppSetting(key); - Future getFolderIdByName(String name) async { - final db = await _open(); - final rows = await db.query('folders', - columns: ['id'], where: 'name = ?', whereArgs: [name]); - if (rows.isEmpty) return null; - return _sqliteInt(rows.first['id']); - } + Future readErdLayout(int connectionId, String scope) => + settings.readErdLayout(connectionId, scope); - /// Returns all saved connections. - /// - /// By default, passwords and connection strings are NOT eagerly hydrated from - /// the platform secure store to avoid IPC bottlenecks, Keychain lockups, and - /// D-Bus timeouts during sidebar/startup population. Secrets are resolved - /// on-demand when a connection is initiated. - Future> getConnections( - {bool hydrateSecrets = false}) async { - final db = await _open(); - final rows = - await db.query('connections', orderBy: 'sort_order ASC, name ASC'); - if (!hydrateSecrets) { - return rows.map((m) => ConnectionRow.fromMap(m)).toList(); - } - final futures = - rows.map((m) => _hydrateConnection(ConnectionRow.fromMap(m))); - return Future.wait(futures); - } + Future writeErdLayout( + int connectionId, + String scope, + String payload, + ) => + settings.writeErdLayout(connectionId, scope, payload); - static Future _hydrateConnection(ConnectionRow row) async { - if (row.id == null) return row; - try { - final secrets = await ConnectionSecretsStore.readForConnection(row.id!); - return row.copyWith( - password: secrets.password ?? row.password, - connectionString: secrets.connectionString ?? row.connectionString, - ); - } catch (e) { - debugPrint( - 'LocalDb._hydrateConnection failed for connection ${row.id}: $e'); - return row; - } - } + Future> getConnections({bool hydrateSecrets = false}) => + connections.getConnections(hydrateSecrets: hydrateSecrets); - /// Hydrates secrets for a single [ConnectionRow] on demand. Future hydrateConnection(ConnectionRow row) => - _hydrateConnection(row); + connections.hydrateConnection(row); - /// Retrieves a single connection by [id], optionally hydrating secrets. Future getConnectionById(int id, - {bool hydrateSecrets = false}) async { - final db = await _open(); - final rows = - await db.query('connections', where: 'id = ?', whereArgs: [id]); - if (rows.isEmpty) return null; - final row = ConnectionRow.fromMap(rows.first); - if (!hydrateSecrets) return row; - return _hydrateConnection(row); - } - - /// Inserts a row and returns the SQLite row id. - /// Password and connection string are stored in the OS secure store, not in SQLite. - /// - /// If writing secrets fails, the SQLite row is rolled back and the error is - /// rethrown so callers can surface a Keychain / libsecret failure. - Future addConnection(ConnectionRow row) async { - final db = await _open(); - final id = await db.insert('connections', row.toPersistenceMap()); - try { - await ConnectionSecretsStore.writeForConnection( - id, - password: row.password, - connectionString: row.connectionString, - ); - if (row.sshSecrets != null) { - await ConnectionSecretsStore.writeSshSecretsForConnection( - id, - password: row.sshSecrets!.password, - privateKey: row.sshSecrets!.privateKey, - passphrase: row.sshSecrets!.passphrase, - jumpPassword: row.sshSecrets!.jumpPassword, - ); - } - } catch (e) { - try { - await ConnectionSecretsStore.deleteForConnection(id); - } catch (_) { - // Best-effort cleanup of any partial secret writes. - } - await db.delete('connections', where: 'id = ?', whereArgs: [id]); - rethrow; - } - return id; - } + {bool hydrateSecrets = false}) => + connections.getConnectionById(id, hydrateSecrets: hydrateSecrets); - /// Updates an existing connection row in SQLite and its secrets in the secure store. - /// - /// If writing secrets fails, the previous SQLite row and previous secrets are - /// restored (best effort) and the error is rethrown. - Future updateConnection(ConnectionRow row) async { - if (row.id == null) { - throw ArgumentError( - 'ConnectionRow.id cannot be null when calling updateConnection'); - } - final db = await _open(); - final previousMaps = await db.query( - 'connections', - where: 'id = ?', - whereArgs: [row.id], - ); - if (previousMaps.isEmpty) { - throw ArgumentError('No connection found with id ${row.id}'); - } - final previousRow = ConnectionRow.fromMap(previousMaps.first); - final previousSecrets = - await ConnectionSecretsStore.readForConnection(row.id!); - final previousSsh = - await ConnectionSecretsStore.readSshSecretsForConnection(row.id!); + Future addConnection(ConnectionRow row) => + connections.addConnection(row); - await db.transaction((txn) async { - final count = await txn.update( - 'connections', - row.toPersistenceMap(), - where: 'id = ?', - whereArgs: [row.id], - ); - if (count == 0) { - throw ArgumentError('No connection found with id ${row.id}'); - } - }); + Future updateConnection(ConnectionRow row) => + connections.updateConnection(row); - try { - await ConnectionSecretsStore.writeForConnection( - row.id!, - password: row.password, - connectionString: row.connectionString, - ); - if (row.sshSecrets != null) { - await ConnectionSecretsStore.writeSshSecretsForConnection( - row.id!, - password: row.sshSecrets!.password, - privateKey: row.sshSecrets!.privateKey, - passphrase: row.sshSecrets!.passphrase, - jumpPassword: row.sshSecrets!.jumpPassword, - ); - } else if (row.sshTunnelConfig?.enabled != true) { - // The tunnel was turned off: its password and key do not stay in the - // keyring (#1311). - await ConnectionSecretsStore.writeSshSecretsForConnection(row.id!); - } - } catch (e) { - await db.update( - 'connections', - previousRow.toPersistenceMap(), - where: 'id = ?', - whereArgs: [row.id], - ); - try { - await ConnectionSecretsStore.writeForConnection( - row.id!, - password: previousSecrets.password, - connectionString: previousSecrets.connectionString, - ); - // The SSH secrets may be half written too (four keys, one by one). - await ConnectionSecretsStore.writeSshSecretsForConnection( - row.id!, - password: previousSsh.password, - privateKey: previousSsh.privateKey, - passphrase: previousSsh.passphrase, - jumpPassword: previousSsh.jumpPassword, - ); - } catch (_) { - // Best-effort restore of previous secrets; surface the original error. - } - rethrow; - } - } + Future removeConnection(int id) => connections.removeConnection(id); - /// Deletes a connection. SQLite deletion always proceeds even if the secure - /// store delete fails (e.g. missing key or unavailable libsecret daemon). - Future removeConnection(int id) async { - _historyInsertCounts.removeWhere((k, _) => k.startsWith('$id::')); - try { - await ConnectionSecretsStore.deleteForConnection(id); - } catch (_) { - // Do not block removing the connection metadata when the OS store fails. - } + /// Queries an active PRAGMA setting from the database for verification and diagnostic purposes. + Future getPragma(String pragmaName) async { final db = await _open(); - await db.delete('connections', where: 'id = ?', whereArgs: [id]); + final res = await db.rawQuery('PRAGMA $pragmaName'); + if (res.isNotEmpty && res.first.values.isNotEmpty) { + return res.first.values.first.toString(); + } + return ''; } /// Resolves the absolute path to `querya.db`. @@ -1028,386 +359,3 @@ class LocalDb { _cachedDbPath = null; } } - -/// Maximum rows kept in the mutation audit trail. -const int kMutationAuditCap = 10000; - -/// Where an audited mutation was started. -enum MutationAuditSource { - sqlEditor('sql_editor'), - tableEditor('table_editor'); - - const MutationAuditSource(this.storageValue); - - final String storageValue; - - static MutationAuditSource fromStorageValue(String? value) => - values.firstWhere( - (s) => s.storageValue == value, - orElse: () => MutationAuditSource.sqlEditor, - ); -} - -/// One row of the local mutation audit trail. -/// Rows kept in `mcp_activity`. -const kMcpActivityCap = 200; - -/// One tool call made by an MCP client. -class McpActivityEntry { - const McpActivityEntry({ - this.id, - required this.recordedAt, - required this.client, - required this.tool, - this.connectionId, - this.connectionName, - this.sqlText, - this.rowCount, - required this.durationMs, - this.error, - this.refusalRule, - }); - - final int? id; - - /// UTC ISO-8601. - final String recordedAt; - final String client; - final String tool; - final int? connectionId; - final String? connectionName; - final String? sqlText; - final int? rowCount; - final int durationMs; - final String? error; - - /// The guard rule that refused the call (`read_only_only`, ...), when one did. - final String? refusalRule; - - Map toMap() => { - 'id': id, - 'recorded_at': recordedAt, - 'client': client, - 'tool': tool, - 'connection_id': connectionId, - 'connection_name': connectionName, - 'sql_text': sqlText, - 'row_count': rowCount, - 'duration_ms': durationMs, - 'error': error, - 'refusal_rule': refusalRule, - }; - - static McpActivityEntry fromMap(Map m) => McpActivityEntry( - id: _sqliteInt(m['id']), - recordedAt: m['recorded_at'] as String, - client: m['client'] as String, - tool: m['tool'] as String, - connectionId: _sqliteInt(m['connection_id']), - connectionName: m['connection_name'] as String?, - sqlText: m['sql_text'] as String?, - rowCount: _sqliteInt(m['row_count']), - durationMs: _sqliteInt(m['duration_ms']) ?? 0, - error: m['error'] as String?, - refusalRule: m['refusal_rule'] as String?, - ); -} - -class MutationAuditEntry { - const MutationAuditEntry({ - required this.id, - this.connectionId, - required this.connectionName, - this.environment, - this.databaseName, - required this.sqlText, - this.rowsAffected, - required this.source, - required this.recordedAt, - }); - - final int id; - final int? connectionId; - final String connectionName; - - /// `development` / `staging` / `production` when the connection was tagged. - final String? environment; - final String? databaseName; - final String sqlText; - final int? rowsAffected; - final MutationAuditSource source; - final String recordedAt; - - static MutationAuditEntry fromMap(Map m) => - MutationAuditEntry( - id: _sqliteInt(m['id'])!, - connectionId: _sqliteInt(m['connection_id']), - connectionName: m['connection_name'] as String, - environment: m['environment'] as String?, - databaseName: m['database_name'] as String?, - sqlText: m['sql_text'] as String, - rowsAffected: _sqliteInt(m['rows_affected']), - source: MutationAuditSource.fromStorageValue(m['source'] as String?), - recordedAt: m['recorded_at'] as String, - ); -} - -/// One row from [LocalDb.sql_query_history] (recent SQL, no secrets). -class SqlQueryHistoryEntry { - const SqlQueryHistoryEntry({ - required this.id, - required this.connectionId, - this.databaseName, - required this.sqlText, - required this.recordedAt, - }); - - final int id; - final int connectionId; - final String? databaseName; - final String sqlText; - final String recordedAt; - - static SqlQueryHistoryEntry fromMap(Map m) => - SqlQueryHistoryEntry( - id: _sqliteInt(m['id'])!, - connectionId: _sqliteInt(m['connection_id'])!, - databaseName: m['database_name'] as String?, - sqlText: m['sql_text'] as String, - recordedAt: m['recorded_at'] as String, - ); -} - -class ConnectionRow { - const ConnectionRow({ - this.id, - required this.type, - required this.name, - this.host, - this.port, - this.username, - this.password, - this.databaseName, - this.authSource, - this.useSSL = false, - this.connectionString, - this.extensionId, - this.driverOptions, - this.folderId, - this.sortOrder = 0, - required this.createdAt, - this.sshSecrets, - this.removeSavedPassword = false, - }); - - /// Set by an edit form (never stored): the saved password is to be removed, - /// not kept because the password field was left blank (#1311). - final bool removeSavedPassword; - - final int? id; - final String type; - final String name; - final String? host; - final int? port; - final String? username; - final String? password; - final String? databaseName; - final String? authSource; - final bool useSSL; - final String? connectionString; - final SshTunnelSecrets? sshSecrets; - - /// Package id of an installed extension driver (null for built-ins). - final String? extensionId; - - /// Non-secret driver-specific form values as JSON text. - final String? driverOptions; - - final int? folderId; - final int sortOrder; - final String createdAt; - - /// True when this row is backed by an installed extension driver. - bool get isExtensionDriver => - extensionId != null && extensionId!.trim().isNotEmpty; - - /// Returns the SSH tunnel config stored in [driverOptions], if any. - SshTunnelConfig? get sshTunnelConfig { - if (driverOptions == null || driverOptions!.trim().isEmpty) return null; - try { - final decoded = jsonDecode(driverOptions!); - if (decoded is Map && decoded.containsKey('ssh_tunnel')) { - final sshMap = decoded['ssh_tunnel']; - if (sshMap is Map) { - return SshTunnelConfig.fromMap(sshMap); - } - } - } catch (e) { - logIgnored('local_db', e); - } - return null; - } - - /// Environment tag stored in [driverOptions] (null when untagged). - ConnectionEnvironment? get environment => - ConnectionEnvironment.fromDriverOptions(driverOptions); - - /// Returns a copy of this row tagged with [environment] (null removes the tag). - ConnectionRow withEnvironment(ConnectionEnvironment? environment) { - final options = - ConnectionEnvironment.applyToDriverOptions(driverOptions, environment); - return copyWith( - driverOptions: options, - clearDriverOptions: options == null, - ); - } - - /// Returns a copy of this row with the given [sshTunnelConfig] encoded in [driverOptions]. - ConnectionRow withSshTunnelConfig(SshTunnelConfig? config) { - Map opts = {}; - if (driverOptions != null && driverOptions!.trim().isNotEmpty) { - try { - final decoded = jsonDecode(driverOptions!); - if (decoded is Map) { - opts = Map.from(decoded); - } - } catch (e) { - logIgnored('local_db', e); - } - } - if (config == null || !config.enabled) { - opts.remove('ssh_tunnel'); - } else { - opts['ssh_tunnel'] = config.toMap(); - } - return copyWith( - driverOptions: opts.isEmpty ? null : jsonEncode(opts), - clearDriverOptions: opts.isEmpty, - ); - } - - Map toMap() => { - 'type': type, - 'name': name, - 'host': host, - 'port': port, - 'username': username, - 'password': password, - 'database_name': databaseName, - 'auth_source': authSource, - 'use_ssl': useSSL ? 1 : 0, - 'connection_string': connectionString, - 'extension_id': extensionId, - 'driver_options': driverOptions, - 'folder_id': folderId, - 'sort_order': sortOrder, - 'created_at': createdAt, - }; - - /// SQLite row without secrets ([password], [connectionString]); use the secure store for those. - Map toPersistenceMap() => { - 'type': type, - 'name': name, - 'host': host, - 'port': port, - 'username': username, - 'password': null, - 'database_name': databaseName, - 'auth_source': authSource, - 'use_ssl': useSSL ? 1 : 0, - 'connection_string': null, - 'extension_id': extensionId, - 'driver_options': driverOptions, - 'folder_id': folderId, - 'sort_order': sortOrder, - 'created_at': createdAt, - }; - - static ConnectionRow fromMap(Map m) => ConnectionRow( - id: _sqliteInt(m['id']), - type: m['type'] as String, - name: m['name'] as String, - host: m['host'] as String?, - port: _sqliteInt(m['port']), - username: m['username'] as String?, - password: m['password'] as String?, - databaseName: m['database_name'] as String?, - authSource: m['auth_source'] as String?, - useSSL: _sqliteInt(m['use_ssl']) == 1, - connectionString: m['connection_string'] as String?, - extensionId: m['extension_id'] as String?, - driverOptions: m['driver_options'] as String?, - folderId: _sqliteInt(m['folder_id']), - sortOrder: _sqliteInt(m['sort_order']) ?? 0, - createdAt: m['created_at'] as String, - ); - - ConnectionRow copyWith({ - int? id, - String? type, - String? name, - String? host, - int? port, - String? username, - String? password, - String? databaseName, - String? authSource, - bool? useSSL, - String? connectionString, - String? extensionId, - String? driverOptions, - int? folderId, - int? sortOrder, - String? createdAt, - SshTunnelSecrets? sshSecrets, - bool? removeSavedPassword, - bool clearPassword = false, - bool clearConnectionString = false, - bool clearSshSecrets = false, - bool clearDriverOptions = false, - }) { - return ConnectionRow( - id: id ?? this.id, - type: type ?? this.type, - name: name ?? this.name, - host: host ?? this.host, - port: port ?? this.port, - username: username ?? this.username, - password: clearPassword ? null : (password ?? this.password), - databaseName: databaseName ?? this.databaseName, - authSource: authSource ?? this.authSource, - useSSL: useSSL ?? this.useSSL, - connectionString: clearConnectionString - ? null - : (connectionString ?? this.connectionString), - extensionId: extensionId ?? this.extensionId, - driverOptions: clearDriverOptions - ? null - : (driverOptions ?? this.driverOptions), - folderId: folderId ?? this.folderId, - sortOrder: sortOrder ?? this.sortOrder, - createdAt: createdAt ?? this.createdAt, - sshSecrets: clearSshSecrets ? null : (sshSecrets ?? this.sshSecrets), - removeSavedPassword: removeSavedPassword ?? this.removeSavedPassword, - ); - } - - /// Whether in-memory password credentials are set. - bool get hasPassword => password != null && password!.isNotEmpty; - - /// Whether in-memory connection URI credentials are set. - bool get hasConnectionString => - connectionString != null && connectionString!.isNotEmpty; - - /// Whether any in-memory secret credentials are held. - bool get hasSecrets => - hasPassword || hasConnectionString || (sshSecrets != null && sshSecrets!.hasAny); - - /// Returns a clean copy of this [ConnectionRow] with all secret credentials - /// ([password], [connectionString], and [sshSecrets]) scrubbed to null. - ConnectionRow withoutSecrets() => copyWith( - clearPassword: true, - clearConnectionString: true, - clearSshSecrets: true, - ); -} diff --git a/lib/core/storage/local_db_models.dart b/lib/core/storage/local_db_models.dart new file mode 100644 index 00000000..b7cc3e26 --- /dev/null +++ b/lib/core/storage/local_db_models.dart @@ -0,0 +1,405 @@ +import 'dart:convert'; + +import 'package:querya_desktop/core/security/connection_environment.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/util/log_ignored.dart'; + +/// Fallback when [recordSqlQueryHistory] is called without `maxEntries`. +/// Keep in sync with [kDefaultSqlHistoryMaxEntries] in `app_settings.dart`. +const int kDefaultSqlHistoryCap = 100; + +String? normalizeHistoryDatabaseName(String? databaseName) { + final t = databaseName?.trim(); + if (t == null || t.isEmpty) return null; + return t; +} + +/// Maximum rows kept in the mutation audit trail. +const int kMutationAuditCap = 10000; + +int? sqliteInt(Object? v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + return int.tryParse(v.toString()); +} + +/// Where an audited mutation was started. +enum MutationAuditSource { + sqlEditor('sql_editor'), + tableEditor('table_editor'); + + const MutationAuditSource(this.storageValue); + + final String storageValue; + + static MutationAuditSource fromStorageValue(String? value) => + values.firstWhere( + (s) => s.storageValue == value, + orElse: () => MutationAuditSource.sqlEditor, + ); +} + +/// One row of the local mutation audit trail. +/// Rows kept in `mcp_activity`. +const kMcpActivityCap = 200; + +/// One tool call made by an MCP client. +class McpActivityEntry { + const McpActivityEntry({ + this.id, + required this.recordedAt, + required this.client, + required this.tool, + this.connectionId, + this.connectionName, + this.sqlText, + this.rowCount, + required this.durationMs, + this.error, + this.refusalRule, + }); + + final int? id; + + /// UTC ISO-8601. + final String recordedAt; + final String client; + final String tool; + final int? connectionId; + final String? connectionName; + final String? sqlText; + final int? rowCount; + final int durationMs; + final String? error; + + /// The guard rule that refused the call (`read_only_only`, ...), when one did. + final String? refusalRule; + + Map toMap() => { + 'id': id, + 'recorded_at': recordedAt, + 'client': client, + 'tool': tool, + 'connection_id': connectionId, + 'connection_name': connectionName, + 'sql_text': sqlText, + 'row_count': rowCount, + 'duration_ms': durationMs, + 'error': error, + 'refusal_rule': refusalRule, + }; + + static McpActivityEntry fromMap(Map m) => McpActivityEntry( + id: sqliteInt(m['id']), + recordedAt: m['recorded_at'] as String, + client: m['client'] as String, + tool: m['tool'] as String, + connectionId: sqliteInt(m['connection_id']), + connectionName: m['connection_name'] as String?, + sqlText: m['sql_text'] as String?, + rowCount: sqliteInt(m['row_count']), + durationMs: sqliteInt(m['duration_ms']) ?? 0, + error: m['error'] as String?, + refusalRule: m['refusal_rule'] as String?, + ); +} + +class MutationAuditEntry { + const MutationAuditEntry({ + required this.id, + this.connectionId, + required this.connectionName, + this.environment, + this.databaseName, + required this.sqlText, + this.rowsAffected, + required this.source, + required this.recordedAt, + }); + + final int id; + final int? connectionId; + final String connectionName; + + /// `development` / `staging` / `production` when the connection was tagged. + final String? environment; + final String? databaseName; + final String sqlText; + final int? rowsAffected; + final MutationAuditSource source; + final String recordedAt; + + static MutationAuditEntry fromMap(Map m) => + MutationAuditEntry( + id: sqliteInt(m['id'])!, + connectionId: sqliteInt(m['connection_id']), + connectionName: m['connection_name'] as String, + environment: m['environment'] as String?, + databaseName: m['database_name'] as String?, + sqlText: m['sql_text'] as String, + rowsAffected: sqliteInt(m['rows_affected']), + source: MutationAuditSource.fromStorageValue(m['source'] as String?), + recordedAt: m['recorded_at'] as String, + ); +} + +/// One row from [LocalDb.sql_query_history] (recent SQL, no secrets). +class SqlQueryHistoryEntry { + const SqlQueryHistoryEntry({ + required this.id, + required this.connectionId, + this.databaseName, + required this.sqlText, + required this.recordedAt, + }); + + final int id; + final int connectionId; + final String? databaseName; + final String sqlText; + final String recordedAt; + + static SqlQueryHistoryEntry fromMap(Map m) => + SqlQueryHistoryEntry( + id: sqliteInt(m['id'])!, + connectionId: sqliteInt(m['connection_id'])!, + databaseName: m['database_name'] as String?, + sqlText: m['sql_text'] as String, + recordedAt: m['recorded_at'] as String, + ); +} + +class ConnectionRow { + const ConnectionRow({ + this.id, + required this.type, + required this.name, + this.host, + this.port, + this.username, + this.password, + this.databaseName, + this.authSource, + this.useSSL = false, + this.connectionString, + this.extensionId, + this.driverOptions, + this.folderId, + this.sortOrder = 0, + required this.createdAt, + this.sshSecrets, + this.removeSavedPassword = false, + }); + + /// Set by an edit form (never stored): the saved password is to be removed, + /// not kept because the password field was left blank (#1311). + final bool removeSavedPassword; + + final int? id; + final String type; + final String name; + final String? host; + final int? port; + final String? username; + final String? password; + final String? databaseName; + final String? authSource; + final bool useSSL; + final String? connectionString; + final SshTunnelSecrets? sshSecrets; + + /// Package id of an installed extension driver (null for built-ins). + final String? extensionId; + + /// Non-secret driver-specific form values as JSON text. + final String? driverOptions; + + final int? folderId; + final int sortOrder; + final String createdAt; + + /// True when this row is backed by an installed extension driver. + bool get isExtensionDriver => + extensionId != null && extensionId!.trim().isNotEmpty; + + /// Returns the SSH tunnel config stored in [driverOptions], if any. + SshTunnelConfig? get sshTunnelConfig { + if (driverOptions == null || driverOptions!.trim().isEmpty) return null; + try { + final decoded = jsonDecode(driverOptions!); + if (decoded is Map && decoded.containsKey('ssh_tunnel')) { + final sshMap = decoded['ssh_tunnel']; + if (sshMap is Map) { + return SshTunnelConfig.fromMap(sshMap); + } + } + } catch (e) { + logIgnored('local_db', e); + } + return null; + } + + /// Environment tag stored in [driverOptions] (null when untagged). + ConnectionEnvironment? get environment => + ConnectionEnvironment.fromDriverOptions(driverOptions); + + /// Returns a copy of this row tagged with [environment] (null removes the tag). + ConnectionRow withEnvironment(ConnectionEnvironment? environment) { + final options = + ConnectionEnvironment.applyToDriverOptions(driverOptions, environment); + return copyWith( + driverOptions: options, + clearDriverOptions: options == null, + ); + } + + /// Returns a copy of this row with the given [sshTunnelConfig] encoded in [driverOptions]. + ConnectionRow withSshTunnelConfig(SshTunnelConfig? config) { + Map opts = {}; + if (driverOptions != null && driverOptions!.trim().isNotEmpty) { + try { + final decoded = jsonDecode(driverOptions!); + if (decoded is Map) { + opts = Map.from(decoded); + } + } catch (e) { + logIgnored('local_db', e); + } + } + if (config == null || !config.enabled) { + opts.remove('ssh_tunnel'); + } else { + opts['ssh_tunnel'] = config.toMap(); + } + return copyWith( + driverOptions: opts.isEmpty ? null : jsonEncode(opts), + clearDriverOptions: opts.isEmpty, + ); + } + + Map toMap() => { + 'type': type, + 'name': name, + 'host': host, + 'port': port, + 'username': username, + 'password': password, + 'database_name': databaseName, + 'auth_source': authSource, + 'use_ssl': useSSL ? 1 : 0, + 'connection_string': connectionString, + 'extension_id': extensionId, + 'driver_options': driverOptions, + 'folder_id': folderId, + 'sort_order': sortOrder, + 'created_at': createdAt, + }; + + /// SQLite row without secrets ([password], [connectionString]); use the secure store for those. + Map toPersistenceMap() => { + 'type': type, + 'name': name, + 'host': host, + 'port': port, + 'username': username, + 'password': null, + 'database_name': databaseName, + 'auth_source': authSource, + 'use_ssl': useSSL ? 1 : 0, + 'connection_string': null, + 'extension_id': extensionId, + 'driver_options': driverOptions, + 'folder_id': folderId, + 'sort_order': sortOrder, + 'created_at': createdAt, + }; + + static ConnectionRow fromMap(Map m) => ConnectionRow( + id: sqliteInt(m['id']), + type: m['type'] as String, + name: m['name'] as String, + host: m['host'] as String?, + port: sqliteInt(m['port']), + username: m['username'] as String?, + password: m['password'] as String?, + databaseName: m['database_name'] as String?, + authSource: m['auth_source'] as String?, + useSSL: sqliteInt(m['use_ssl']) == 1, + connectionString: m['connection_string'] as String?, + extensionId: m['extension_id'] as String?, + driverOptions: m['driver_options'] as String?, + folderId: sqliteInt(m['folder_id']), + sortOrder: sqliteInt(m['sort_order']) ?? 0, + createdAt: m['created_at'] as String, + ); + + ConnectionRow copyWith({ + int? id, + String? type, + String? name, + String? host, + int? port, + String? username, + String? password, + String? databaseName, + String? authSource, + bool? useSSL, + String? connectionString, + String? extensionId, + String? driverOptions, + int? folderId, + int? sortOrder, + String? createdAt, + SshTunnelSecrets? sshSecrets, + bool? removeSavedPassword, + bool clearPassword = false, + bool clearConnectionString = false, + bool clearSshSecrets = false, + bool clearDriverOptions = false, + }) { + return ConnectionRow( + id: id ?? this.id, + type: type ?? this.type, + name: name ?? this.name, + host: host ?? this.host, + port: port ?? this.port, + username: username ?? this.username, + password: clearPassword ? null : (password ?? this.password), + databaseName: databaseName ?? this.databaseName, + authSource: authSource ?? this.authSource, + useSSL: useSSL ?? this.useSSL, + connectionString: clearConnectionString + ? null + : (connectionString ?? this.connectionString), + extensionId: extensionId ?? this.extensionId, + driverOptions: clearDriverOptions + ? null + : (driverOptions ?? this.driverOptions), + folderId: folderId ?? this.folderId, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + sshSecrets: clearSshSecrets ? null : (sshSecrets ?? this.sshSecrets), + removeSavedPassword: removeSavedPassword ?? this.removeSavedPassword, + ); + } + + /// Whether in-memory password credentials are set. + bool get hasPassword => password != null && password!.isNotEmpty; + + /// Whether in-memory connection URI credentials are set. + bool get hasConnectionString => + connectionString != null && connectionString!.isNotEmpty; + + /// Whether any in-memory secret credentials are held. + bool get hasSecrets => + hasPassword || hasConnectionString || (sshSecrets != null && sshSecrets!.hasAny); + + /// Returns a clean copy of this [ConnectionRow] with all secret credentials + /// ([password], [connectionString], and [sshSecrets]) scrubbed to null. + ConnectionRow withoutSecrets() => copyWith( + clearPassword: true, + clearConnectionString: true, + clearSshSecrets: true, + ); +} diff --git a/lib/core/storage/local_db_schema.dart b/lib/core/storage/local_db_schema.dart new file mode 100644 index 00000000..dcd222aa --- /dev/null +++ b/lib/core/storage/local_db_schema.dart @@ -0,0 +1,295 @@ +import 'dart:math'; + +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// `app_settings` key under which each profile database's random id is +/// stored (see [LocalDbSchema.ensureProfileId] and issue #986). +const profileIdSettingKey = 'profile_id'; + +String _generateProfileId() { + final rand = Random.secure(); + final bytes = List.generate(16, (_) => rand.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} + +int? _sqliteInt(Object? v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + return int.tryParse(v.toString()); +} + +/// The schema of the local database: its creation, the upgrade from every +/// older version, and the pragmas set on each open. +/// +/// [version] is bumped with each schema change; add the step to [onUpgrade] +/// under `oldVersion < N` and the table to [onCreate]. +abstract final class LocalDbSchema { + static const version = 13; + + /// Run on every open, after create or upgrade. + static Future onOpen(Database db) async { + await db.execute('PRAGMA foreign_keys = ON'); + await db.execute('PRAGMA journal_mode = WAL'); + await db.execute('PRAGMA busy_timeout = 5000'); + await db.execute('PRAGMA synchronous = NORMAL'); + } + + static Future onCreate(Database db, int version) async { + await db.execute(''' + CREATE TABLE folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + '''); + await db.execute(''' + CREATE TABLE connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + name TEXT NOT NULL, + host TEXT, + port INTEGER, + username TEXT, + password TEXT, + database_name TEXT, + auth_source TEXT, + use_ssl INTEGER NOT NULL DEFAULT 0, + connection_string TEXT, + extension_id TEXT, + driver_options TEXT, + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE app_settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE TABLE sql_query_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, + database_name TEXT, + sql_text TEXT NOT NULL, + recorded_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE INDEX idx_sql_query_history_lookup + ON sql_query_history (connection_id, database_name, recorded_at DESC, id DESC) + '''); + await createMutationAuditTable(db); + await createMcpActivityTable(db); + await createErdLayoutsTable(db); + ConnectionSecretsStore.profileId = await ensureProfileId(db); + } + + /// Local audit trail of executed data / schema changes (#1062). Not linked + /// to `connections` so entries outlive a deleted connection; the connection + /// name is stored as a snapshot. + static Future createMutationAuditTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS mutation_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id INTEGER, + connection_name TEXT NOT NULL, + environment TEXT, + database_name TEXT, + sql_text TEXT NOT NULL, + rows_affected INTEGER, + source TEXT NOT NULL, + recorded_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE INDEX IF NOT EXISTS idx_mutation_audit_recorded + ON mutation_audit (id DESC) + '''); + } + + /// Calls made by MCP clients (#1136), newest kept, capped at + /// [kMcpActivityCap]. Connection name is a snapshot, like the audit. + static Future createMcpActivityTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS mcp_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at TEXT NOT NULL, + client TEXT NOT NULL, + tool TEXT NOT NULL, + connection_id INTEGER, + connection_name TEXT, + sql_text TEXT, + row_count INTEGER, + duration_ms INTEGER NOT NULL, + error TEXT, + refusal_rule TEXT + ) + '''); + } + + static Future onUpgrade( + Database db, int oldVersion, int newVersion) async { + // Every upgrade path needs app_settings before it can read/write the + // profile id; the oldVersion < 4 block below also creates this table for + // installs that predate it, but IF NOT EXISTS keeps this idempotent. + await db.execute(''' + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ) + '''); + ConnectionSecretsStore.profileId = await ensureProfileId(db); + + if (oldVersion < 2) { + await db.execute('ALTER TABLE connections ADD COLUMN password TEXT'); + await db.execute('ALTER TABLE connections ADD COLUMN database_name TEXT'); + await db.execute('ALTER TABLE connections ADD COLUMN auth_source TEXT'); + await db.execute( + 'ALTER TABLE connections ADD COLUMN use_ssl INTEGER NOT NULL DEFAULT 0'); + await db + .execute('ALTER TABLE connections ADD COLUMN connection_string TEXT'); + } + if (oldVersion < 3) { + await db.execute('PRAGMA foreign_keys=ON'); + await db.execute(''' + CREATE TABLE connections_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + name TEXT NOT NULL, + host TEXT, + port INTEGER, + username TEXT, + password TEXT, + database_name TEXT, + auth_source TEXT, + use_ssl INTEGER NOT NULL DEFAULT 0, + connection_string TEXT, + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + '''); + await db.execute(''' + INSERT INTO connections_new + SELECT id, type, name, host, port, username, password, database_name, + auth_source, use_ssl, connection_string, folder_id, sort_order, created_at + FROM connections + '''); + await db.execute('DROP TABLE connections'); + await db.execute('ALTER TABLE connections_new RENAME TO connections'); + } + // (oldVersion < 4 used to CREATE TABLE app_settings here; the + // CREATE TABLE IF NOT EXISTS above now covers that install path too.) + if (oldVersion < 5) { + final rows = await db.query('connections'); + for (final m in rows) { + final id = _sqliteInt(m['id']); + if (id == null) continue; + final pwd = m['password'] as String?; + final cs = m['connection_string'] as String?; + await ConnectionSecretsStore.writeForConnection( + id, + password: (pwd != null && pwd.isNotEmpty) ? pwd : null, + connectionString: (cs != null && cs.isNotEmpty) ? cs : null, + ); + } + await db.execute( + 'UPDATE connections SET password = NULL, connection_string = NULL', + ); + } + if (oldVersion < 6) { + await db.execute(''' + CREATE TABLE sql_query_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, + database_name TEXT, + sql_text TEXT NOT NULL, + recorded_at TEXT NOT NULL + ) + '''); + await db.execute(''' + CREATE INDEX idx_sql_query_history_lookup + ON sql_query_history (connection_id, recorded_at DESC) + '''); + } + if (oldVersion < 7) { + await db.execute('ALTER TABLE connections ADD COLUMN extension_id TEXT'); + await db + .execute('ALTER TABLE connections ADD COLUMN driver_options TEXT'); + } + if (oldVersion < 8) { + await db.execute('DROP INDEX IF EXISTS idx_sql_query_history_lookup'); + await db.execute(''' + CREATE INDEX idx_sql_query_history_lookup + ON sql_query_history (connection_id, database_name, recorded_at DESC, id DESC) + '''); + } + if (oldVersion < 9) { + // Adopt this profile's own pre-#986 unnamespaced keyring secrets (if + // any) under the namespaced key generated above, so this database's + // connections keep working after another profile with an overlapping + // connection id also upgrades. + final rows = await db.query('connections', columns: ['id']); + for (final m in rows) { + final id = _sqliteInt(m['id']); + if (id == null) continue; + await ConnectionSecretsStore.adoptLegacyKeysForConnection(id); + } + } + if (oldVersion < 10) { + await createMutationAuditTable(db); + } + if (oldVersion < 11) { + await createMcpActivityTable(db); + } + if (oldVersion >= 11 && oldVersion < 12) { + // The rule that refused a call, next to its error text. + await db.execute('ALTER TABLE mcp_activity ADD COLUMN refusal_rule TEXT'); + } + if (oldVersion < 13) { + await createErdLayoutsTable(db); + } + } + + /// What the user arranged on an ER diagram (#1275), one row per connection + /// and scope (database / schema), removed with its connection. + static Future createErdLayoutsTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS erd_layouts ( + connection_id INTEGER NOT NULL + REFERENCES connections(id) ON DELETE CASCADE, + scope TEXT NOT NULL, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (connection_id, scope) + ) + '''); + } + + + /// Returns this profile database's random id (see issue #986), generating + /// and persisting one on first use. Namespaces [ConnectionSecretsStore] + /// keys so two profile databases never collide in the shared OS keyring. + static Future ensureProfileId(Database db) async { + final rows = await db.query( + 'app_settings', + columns: ['value'], + where: 'key = ?', + whereArgs: [profileIdSettingKey], + limit: 1, + ); + final existing = rows.isNotEmpty ? rows.first['value'] as String? : null; + if (existing != null && existing.isNotEmpty) return existing; + final id = _generateProfileId(); + await db.rawInsert( + 'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', + [profileIdSettingKey, id], + ); + return id; + } +} diff --git a/lib/core/storage/sql_query_history_repository.dart b/lib/core/storage/sql_query_history_repository.dart new file mode 100644 index 00000000..9566eac4 --- /dev/null +++ b/lib/core/storage/sql_query_history_repository.dart @@ -0,0 +1,148 @@ +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; + +/// The SQL history of each connection and database, capped per bucket. +/// Part of [LocalDb], which keeps the same methods and delegates here. +class SqlQueryHistoryRepository { + SqlQueryHistoryRepository(this._open); + + final Future Function() _open; + + /// Inserts per history bucket since the last prune, so a prune runs every few + /// inserts and not on each one. + final Map _historyInsertCounts = {}; + + /// Forgets the insert counts of a removed connection. + void forgetConnection(int connectionId) => _historyInsertCounts + .removeWhere((k, _) => k.startsWith('$connectionId::')); + + Future recordSqlQueryHistory({ + required int connectionId, + String? databaseName, + required String sqlText, + int maxEntries = kDefaultSqlHistoryCap, + bool forcePrune = false, + }) async { + final sql = sqlText.trim(); + if (sql.isEmpty) return; + if (maxEntries < 1) return; + final db = await _open(); + final dbKey = normalizeHistoryDatabaseName(databaseName); + final now = DateTime.now().toUtc().toIso8601String(); + await db.insert('sql_query_history', { + 'connection_id': connectionId, + 'database_name': dbKey, + 'sql_text': sql, + 'recorded_at': now, + }); + + final bucketKey = '$connectionId::${dbKey ?? ''}'; + final insertCount = (_historyInsertCounts[bucketKey] ?? 0) + 1; + _historyInsertCounts[bucketKey] = insertCount; + + final batchThreshold = maxEntries <= 10 ? 1 : 10; + if (forcePrune || insertCount >= batchThreshold) { + _historyInsertCounts[bucketKey] = 0; + await _pruneSqlQueryHistoryBucket( + db, + connectionId: connectionId, + databaseName: dbKey, + maxEntries: maxEntries, + ); + } + } + + /// Keeps the newest [maxEntries] rows in a (connection, database) bucket. + /// + /// Selects overflow ids (oldest first), then deletes by primary key — avoids + /// nested `DELETE … SELECT … OFFSET` plans as history grows. + Future _pruneSqlQueryHistoryBucket( + Database db, { + required int connectionId, + required String? databaseName, + required int maxEntries, + }) async { + final countRows = await db.rawQuery( + ''' + SELECT COUNT(*) AS c FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ''', + [connectionId, databaseName], + ); + final count = sqliteInt(countRows.first['c']) ?? 0; + final excess = count - maxEntries; + if (excess <= 0) return; + + final overflow = await db.rawQuery( + ''' + SELECT id FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ORDER BY id ASC + LIMIT ? + ''', + [connectionId, databaseName, excess], + ); + if (overflow.isEmpty) return; + + final ids = []; + for (final row in overflow) { + final id = row['id']; + if (id != null) ids.add(id); + } + if (ids.isEmpty) return; + + final placeholders = List.filled(ids.length, '?').join(','); + await db.rawDelete( + 'DELETE FROM sql_query_history WHERE id IN ($placeholders)', + ids, + ); + } + + Future> listSqlQueryHistory({ + required int connectionId, + String? databaseName, + int limit = 50, + }) async { + final db = await _open(); + final dbKey = normalizeHistoryDatabaseName(databaseName); + final rows = await db.rawQuery( + ''' + SELECT id, connection_id, database_name, sql_text, recorded_at + FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ORDER BY id DESC + LIMIT ? + ''', + [connectionId, dbKey, limit], + ); + return rows.map(SqlQueryHistoryEntry.fromMap).toList(); + } + + /// Removes all history rows for [connectionId] (every database bucket). + Future clearSqlQueryHistoryForConnection(int connectionId) async { + _historyInsertCounts.removeWhere((k, _) => k.startsWith('$connectionId::')); + final db = await _open(); + await db.delete( + 'sql_query_history', + where: 'connection_id = ?', + whereArgs: [connectionId], + ); + } + + /// Removes history for [connectionId] and [databaseName] only (same bucket as [listSqlQueryHistory]). + Future clearSqlQueryHistoryBucket({ + required int connectionId, + String? databaseName, + }) async { + final dbKey = normalizeHistoryDatabaseName(databaseName); + _historyInsertCounts.remove('$connectionId::${dbKey ?? ''}'); + final db = await _open(); + await db.rawDelete( + ''' + DELETE FROM sql_query_history + WHERE connection_id = ? AND database_name IS NOT DISTINCT FROM ? + ''', + [connectionId, dbKey], + ); + } +} diff --git a/test/core/storage/local_db_schema_test.dart b/test/core/storage/local_db_schema_test.dart new file mode 100644 index 00000000..f3dd3778 --- /dev/null +++ b/test/core/storage/local_db_schema_test.dart @@ -0,0 +1,155 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db_schema.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// The schema is created whole for a new install and reached step by step from +/// every older version (#1502). +void main() { + late Directory dir; + var n = 0; + + setUpAll(() { + sqfliteFfiInit(); + dir = Directory.systemTemp.createTempSync('local_db_schema_'); + }); + tearDownAll(() { + try { + dir.deleteSync(recursive: true); + } catch (_) {} + }); + + String path() => '${dir.path}/schema_${n++}.db'; + + Future open(String file, int version, + {OnDatabaseCreateFn? onCreate, OnDatabaseVersionChangeFn? onUpgrade}) { + return databaseFactoryFfi.openDatabase( + file, + options: OpenDatabaseOptions( + version: version, + onCreate: onCreate, + onUpgrade: onUpgrade, + onOpen: LocalDbSchema.onOpen, + ), + ); + } + + Future> tables(Database db) async => { + for (final r in await db.rawQuery( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name NOT LIKE 'sqlite_%'")) + r['name']! as String, + }; + + Future> columns(Database db, String table) async => { + for (final r in await db.rawQuery('PRAGMA table_info($table)')) + r['name']! as String, + }; + + const allTables = { + 'folders', + 'connections', + 'app_settings', + 'sql_query_history', + 'mutation_audit', + 'mcp_activity', + 'erd_layouts', + }; + + test('a new database has every table and a profile id', () async { + final db = await open(path(), LocalDbSchema.version, + onCreate: LocalDbSchema.onCreate); + expect(await tables(db), allTables); + expect(await columns(db, 'connections'), + containsAll(['extension_id', 'driver_options', 'folder_id'])); + expect(await columns(db, 'mcp_activity'), contains('refusal_rule')); + final id = await db.query('app_settings', + where: 'key = ?', whereArgs: [profileIdSettingKey]); + expect(id.single['value'], isNotEmpty); + await db.close(); + }); + + test('the pragmas of an open are set', () async { + final db = await open(path(), LocalDbSchema.version, + onCreate: LocalDbSchema.onCreate); + expect((await db.rawQuery('PRAGMA foreign_keys')).single.values.single, 1); + expect( + (await db.rawQuery('PRAGMA busy_timeout')).single.values.single, 5000); + await db.close(); + }); + + test('version 1 reaches the current schema and keeps its connections', + () async { + final file = path(); + final v1 = await open(file, 1, onCreate: (db, _) async { + await db.execute(''' + CREATE TABLE folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0 + )'''); + await db.execute(''' + CREATE TABLE connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + name TEXT NOT NULL, + host TEXT, + port INTEGER, + username TEXT, + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + )'''); + await db.insert('folders', {'name': 'work'}); + await db.insert('connections', { + 'type': 'postgresql', + 'name': 'prod', + 'host': 'db.example', + 'port': 5432, + 'username': 'app', + 'folder_id': 1, + 'created_at': '2026-01-01', + }); + }); + await v1.close(); + + final db = await open(file, LocalDbSchema.version, + onUpgrade: LocalDbSchema.onUpgrade); + expect(await tables(db), allTables); + expect(await columns(db, 'connections'), + containsAll(['password', 'use_ssl', 'extension_id', 'driver_options'])); + final rows = await db.query('connections'); + expect(rows.single['name'], 'prod'); + expect(rows.single['host'], 'db.example'); + expect(rows.single['folder_id'], 1); + expect(await db.getVersion(), LocalDbSchema.version); + await db.close(); + }); + + test('version 11 gains the refusal column and the ER layouts table', + () async { + final file = path(); + final v11 = await open(file, 11, onCreate: (db, _) async { + await LocalDbSchema.onCreate(db, 11); + await db.execute('DROP TABLE erd_layouts'); + await db.execute('ALTER TABLE mcp_activity DROP COLUMN refusal_rule'); + }); + await v11.close(); + + final db = await open(file, LocalDbSchema.version, + onUpgrade: LocalDbSchema.onUpgrade); + expect(await columns(db, 'mcp_activity'), contains('refusal_rule')); + expect(await tables(db), contains('erd_layouts')); + await db.close(); + }); + + test('the profile id is made once and kept', () async { + final db = await open(path(), LocalDbSchema.version, + onCreate: LocalDbSchema.onCreate); + final first = await LocalDbSchema.ensureProfileId(db); + expect(await LocalDbSchema.ensureProfileId(db), first); + expect(first, hasLength(32)); + await db.close(); + }); +} diff --git a/test/core/storage/storage_repositories_test.dart b/test/core/storage/storage_repositories_test.dart new file mode 100644 index 00000000..6983c08d --- /dev/null +++ b/test/core/storage/storage_repositories_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/activity_log_repository.dart'; +import 'package:querya_desktop/core/storage/app_settings_repository.dart'; +import 'package:querya_desktop/core/storage/folder_repository.dart'; +import 'package:querya_desktop/core/storage/local_db_models.dart'; +import 'package:querya_desktop/core/storage/local_db_schema.dart'; +import 'package:querya_desktop/core/storage/sql_query_history_repository.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// The repositories on a database of their own (in memory, made by +/// [LocalDbSchema]), without [LocalDb] (#1502). +void main() { + late Database db; + + setUpAll(sqfliteFfiInit); + setUp(() async { + db = await databaseFactoryFfi.openDatabase( + inMemoryDatabasePath, + options: OpenDatabaseOptions( + version: LocalDbSchema.version, + onCreate: LocalDbSchema.onCreate, + onOpen: LocalDbSchema.onOpen, + ), + ); + }); + tearDown(() => db.close()); + + Future open() async => db; + + Future insertConnection({int? folderId}) => db.insert('connections', { + 'type': 'sqlite', + 'name': 'c', + 'folder_id': folderId, + 'created_at': '2026-10-18', + }); + + test('folders: added in order, removed without taking their connections', + () async { + final folders = FolderRepository(open); + await folders.addFolder(' work '); + await folders.addFolder('home'); + await folders.addFolder(' '); + expect(await folders.getFolders(), ['home', 'work']); + + final workId = await folders.getFolderIdByName('work'); + expect(workId, isNotNull); + final conn = await insertConnection(folderId: workId); + await folders.removeFolder('work'); + expect(await folders.getFolders(), ['home']); + final rows = await db.query('connections', where: 'id = ?', whereArgs: [conn]); + expect(rows.single['folder_id'], isNull); + }); + + test('app settings: set, replace, delete and ER layouts per scope', () async { + final settings = AppSettingsRepository(open); + expect(await settings.getAppSetting('k'), isNull); + await settings.setAppSetting('k', 'v1'); + await settings.setAppSetting('k', 'v2'); + expect(await settings.getAppSetting('k'), 'v2'); + await settings.deleteAppSetting('k'); + expect(await settings.getAppSetting('k'), isNull); + + final conn = await insertConnection(); + await settings.writeErdLayout(conn, 'public', '{"a":1}'); + await settings.writeErdLayout(conn, 'other', '{"b":2}'); + await settings.writeErdLayout(conn, 'public', '{"a":3}'); + expect(await settings.readErdLayout(conn, 'public'), '{"a":3}'); + expect(await settings.readErdLayout(conn, 'other'), '{"b":2}'); + expect(await settings.readErdLayout(conn, 'none'), isNull); + }); + + test('query history: newest first, capped per bucket, cleared by bucket', + () async { + final history = SqlQueryHistoryRepository(open); + final conn = await insertConnection(); + for (var i = 0; i < 5; i++) { + await history.recordSqlQueryHistory( + connectionId: conn, + databaseName: 'db', + sqlText: 'SELECT $i', + maxEntries: 3, + forcePrune: true, + ); + } + await history.recordSqlQueryHistory( + connectionId: conn, + databaseName: 'other', + sqlText: 'SELECT other', + maxEntries: 3, + ); + final db1 = await history.listSqlQueryHistory( + connectionId: conn, databaseName: 'db'); + expect([for (final e in db1) e.sqlText], ['SELECT 4', 'SELECT 3', 'SELECT 2']); + + await history.clearSqlQueryHistoryBucket( + connectionId: conn, databaseName: 'db'); + expect( + await history.listSqlQueryHistory( + connectionId: conn, databaseName: 'db'), + isEmpty); + expect( + await history.listSqlQueryHistory( + connectionId: conn, databaseName: 'other'), + hasLength(1)); + }); + + test('activity log: MCP calls are listed newest first and clearable', + () async { + final log = ActivityLogRepository(open); + expect(await log.listMcpActivity(), isEmpty); + for (final tool in ['list_tables', 'run_query']) { + await log.recordMcpActivity(McpActivityEntry( + recordedAt: DateTime.now().toUtc().toIso8601String(), + client: 'test', + tool: tool, + durationMs: 3, + refusalRule: tool == 'run_query' ? 'read_only_only' : null, + )); + } + final entries = await log.listMcpActivity(); + expect([for (final e in entries) e.tool], ['run_query', 'list_tables']); + expect(entries.first.refusalRule, 'read_only_only'); + await log.clearMcpActivity(); + expect(await log.listMcpActivity(), isEmpty); + }); +}