Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions lib/core/storage/activity_log_repository.dart
Original file line number Diff line number Diff line change
@@ -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<Database> 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<void> 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<List<MutationAuditEntry>> 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<void> 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<void> 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<List<McpActivityEntry>> 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<void> clearMcpActivity() async {
final db = await _open();
await db.delete('mcp_activity');
}
}
60 changes: 60 additions & 0 deletions lib/core/storage/app_settings_repository.dart
Original file line number Diff line number Diff line change
@@ -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<Database> Function() _open;

Future<String?> 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<void> 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<void> deleteAppSetting(String key) async {
final db = await _open();
await db.delete('app_settings', where: 'key = ?', whereArgs: [key]);
}

Future<String?> 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<void> 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()],
);
}
}
196 changes: 196 additions & 0 deletions lib/core/storage/connection_repository.dart
Original file line number Diff line number Diff line change
@@ -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<Database> 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<List<ConnectionRow>> 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<ConnectionRow> _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<ConnectionRow> hydrateConnection(ConnectionRow row) =>
_hydrateConnection(row);

/// Retrieves a single connection by [id], optionally hydrating secrets.
Future<ConnectionRow?> 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<int> 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<void> 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<void> 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]);
}
}
Loading
Loading