From 552071b2ce2ff698999f9043123718af78b5fac7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 19:44:04 +0300 Subject: [PATCH 1/2] fix(storage): add disaster recovery fallback UI when LocalDb fails to open on startup Closes #1375 --- lib/core/storage/local_db.dart | 76 ++++++ .../recovery/querya_startup_recovery_app.dart | 248 ++++++++++++++++++ lib/main.dart | 30 ++- .../querya_startup_recovery_app_test.dart | 135 ++++++++++ 4 files changed, 481 insertions(+), 8 deletions(-) create mode 100644 lib/features/recovery/querya_startup_recovery_app.dart create mode 100644 test/features/recovery/querya_startup_recovery_app_test.dart diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 02d23a14..ffae4447 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -878,6 +878,82 @@ class LocalDb { await db.delete('connections', where: 'id = ?', whereArgs: [id]); } + /// Resolves the absolute path to `querya.db`. + Future databasePath() async { + if (_cachedDbPath != null) return _cachedDbPath!; + final dir = await AppDataRoot.applicationSupportDirectory(); + final sub = Directory(p.join(dir.path, 'querya_desktop')); + if (!await sub.exists()) await sub.create(recursive: true); + _cachedDbPath = p.join(sub.path, _dbName); + return _cachedDbPath!; + } + + /// Explicitly opens the database or throws if opening/migrating fails. + Future open() => _open(); + + /// Removes stale WAL, SHM, and lock sidecars (e.g. after a process crash or freeze). + Future removeWalAndLockFiles() async { + await close(); + final dbPath = await databasePath(); + final walFile = File('$dbPath-wal'); + final shmFile = File('$dbPath-shm'); + final lockFile = File('$dbPath-lock'); + if (await walFile.exists()) { + try { + await walFile.delete(); + } catch (_) {} + } + if (await shmFile.exists()) { + try { + await shmFile.delete(); + } catch (_) {} + } + if (await lockFile.exists()) { + try { + await lockFile.delete(); + } catch (_) {} + } + } + + /// Creates a timestamped backup copy of `querya.db` (and any sidecar files) + /// in the same directory and returns the backup file path. + Future backupDatabaseFile() async { + await close(); + final dbPath = await databasePath(); + final dbFile = File(dbPath); + if (!await dbFile.exists()) { + throw FileSystemException('Database file does not exist', dbPath); + } + final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + final backupPath = '$dbPath.corrupted.$timestamp.bak'; + await dbFile.copy(backupPath); + + final walFile = File('$dbPath-wal'); + if (await walFile.exists()) { + try { + await walFile.copy('$backupPath-wal'); + } catch (_) {} + } + return backupPath; + } + + /// Resets the database file by creating a backup and removing the corrupted db + /// so a fresh schema can be created on the next launch. + Future resetDatabaseFile() async { + await close(); + final dbPath = await databasePath(); + final dbFile = File(dbPath); + if (await dbFile.exists()) { + try { + await backupDatabaseFile(); + } catch (_) {} + try { + await dbFile.delete(); + } catch (_) {} + } + await removeWalAndLockFiles(); + } + Future close() async { _openFuture = null; await _db?.close(); diff --git a/lib/features/recovery/querya_startup_recovery_app.dart b/lib/features/recovery/querya_startup_recovery_app.dart new file mode 100644 index 00000000..dc48c8f0 --- /dev/null +++ b/lib/features/recovery/querya_startup_recovery_app.dart @@ -0,0 +1,248 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/app/app.dart'; +import 'package:querya_desktop/core/storage/app_data_root.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Disaster recovery UI launched when LocalDb fails to open or migrate on startup. +/// Allows the user to inspect the error, retry, remove stale locks, backup the database, +/// or launch in Safe Mode. +class QueryaStartupRecoveryApp extends material.StatefulWidget { + const QueryaStartupRecoveryApp({ + super.key, + required this.error, + this.stackTrace, + this.onRetrySuccess, + }); + + final Object error; + final StackTrace? stackTrace; + final material.VoidCallback? onRetrySuccess; + + @override + material.State createState() => + _QueryaStartupRecoveryAppState(); +} + +class _QueryaStartupRecoveryAppState + extends material.State { + bool _isBusy = false; + String? _statusMessage; + bool _isErrorStatus = false; + + void _setStatus(String message, {bool isError = false}) { + setState(() { + _statusMessage = message; + _isErrorStatus = isError; + }); + } + + Future _handleRetry() async { + setState(() => _isBusy = true); + _setStatus('Attempting to open database...'); + try { + await LocalDb.instance.open(); + _setStatus('Database opened successfully!'); + if (mounted) { + if (widget.onRetrySuccess != null) { + widget.onRetrySuccess!(); + } else { + material.runApp(const QueryaApp()); + } + } + } catch (e) { + _setStatus('Retry failed: $e', isError: true); + } finally { + if (mounted) setState(() => _isBusy = false); + } + } + + Future _handleUnlockWal() async { + setState(() => _isBusy = true); + _setStatus('Removing WAL and lock files...'); + try { + await LocalDb.instance.removeWalAndLockFiles(); + _setStatus('Stale lock and WAL files removed. Testing database connection...'); + await LocalDb.instance.open(); + _setStatus('Database opened successfully!'); + if (mounted) { + if (widget.onRetrySuccess != null) { + widget.onRetrySuccess!(); + } else { + material.runApp(const QueryaApp()); + } + } + } catch (e) { + _setStatus('Unlock attempted, but database failed to open: $e', + isError: true); + } finally { + if (mounted) setState(() => _isBusy = false); + } + } + + Future _handleBackup() async { + setState(() => _isBusy = true); + _setStatus('Creating timestamped backup...'); + try { + final backupPath = await LocalDb.instance.backupDatabaseFile(); + _setStatus('Backup saved to: $backupPath'); + } catch (e) { + _setStatus('Backup failed: $e', isError: true); + } finally { + if (mounted) setState(() => _isBusy = false); + } + } + + Future _handleSafeMode() async { + setState(() => _isBusy = true); + _setStatus('Switching to clean safe profile...'); + try { + await LocalDb.instance.close(); + final tempDir = await Directory.systemTemp.createTemp('querya_safe_mode_'); + AppDataRoot.mockPortableRootPath = tempDir.path; + await LocalDb.instance.open(); + _setStatus('Safe mode ready. Launching Querya...'); + if (mounted) { + if (widget.onRetrySuccess != null) { + widget.onRetrySuccess!(); + } else { + material.runApp(const QueryaApp()); + } + } + } catch (e) { + _setStatus('Failed to enter safe mode: $e', isError: true); + } finally { + if (mounted) setState(() => _isBusy = false); + } + } + + @override + material.Widget build(material.BuildContext context) { + final themeData = QueryaThemePreset.queryaDark.data; + final colorScheme = themeData.colorScheme; + + return ShadcnApp( + title: 'Querya - Startup Recovery', + theme: ThemeController.instance.darkShadcnTheme, + darkTheme: ThemeController.instance.darkShadcnTheme, + themeMode: ThemeMode.dark, + debugShowCheckedModeBanner: false, + home: material.Scaffold( + backgroundColor: colorScheme.background, + body: material.Center( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(32), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(maxWidth: 640), + child: QueryaDialogCard( + borderColor: colorScheme.destructive, + child: material.Padding( + padding: const material.EdgeInsets.all(28), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + material.Icon( + QueryaIcons.treeError, + size: 32, + color: colorScheme.destructive, + ), + const material.SizedBox(width: 12), + const material.Expanded( + child: Text( + 'Database Recovery', + ), + ), + ], + ), + const material.SizedBox(height: 16), + Text( + 'Querya could not open the profile database (querya.db) on startup. ' + 'This can happen after a crash, concurrent lock, file permission conflict, ' + 'or SQLite database corruption.', + ).muted(), + const material.SizedBox(height: 16), + material.Container( + width: double.infinity, + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: colorScheme.muted.withOpacity(0.3), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: colorScheme.border, + ), + ), + child: material.SelectableText( + widget.error.toString(), + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + if (_statusMessage != null) ...[ + const material.SizedBox(height: 16), + material.Container( + width: double.infinity, + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: (_isErrorStatus + ? colorScheme.destructive + : colorScheme.primary) + .withOpacity(0.15), + borderRadius: material.BorderRadius.circular(6), + ), + child: Text( + _statusMessage!, + style: material.TextStyle( + color: _isErrorStatus + ? colorScheme.destructive + : colorScheme.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500, + ), + ), + ), + ], + const material.SizedBox(height: 24), + material.Wrap( + spacing: 12, + runSpacing: 12, + children: [ + PrimaryButton( + onPressed: _isBusy ? null : _handleRetry, + child: const Text('Retry Opening'), + ), + OutlineButton( + onPressed: _isBusy ? null : _handleUnlockWal, + child: const Text('Unlock Database (Clear WAL)'), + ), + OutlineButton( + onPressed: _isBusy ? null : _handleBackup, + child: const Text('Export / Backup querya.db'), + ), + GhostButton( + onPressed: _isBusy ? null : _handleSafeMode, + child: const Text('Start in Safe Mode'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 2a814eb0..748a4212 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'core/platform/file_launch_service.dart'; import 'core/security/ssl_certificate_support.dart'; import 'core/storage/local_db.dart'; import 'core/theme/theme_controller.dart'; +import 'features/recovery/querya_startup_recovery_app.dart'; import 'features/updater/update_controller.dart'; void main([List args = const []]) async { @@ -30,14 +31,27 @@ void main([List args = const []]) async { DisplayRefreshService.initialize(); await LocalDb.initFfi(); - await SyntaxHighlightService.ensureInitialized(); - await ThemeController.instance.load(); - await UiScaleController.instance.load(); - await QueryaMotionController.instance.load(); - unawaited(UpdateController.instance.initialize()); - unawaited(cleanupStaleMongoTlsTempFiles()); - unawaited(McpServerController.instance.startIfEnabled()); - runApp(const QueryaApp()); + + try { + await LocalDb.instance.open(); + await SyntaxHighlightService.ensureInitialized(); + await ThemeController.instance.load(); + await UiScaleController.instance.load(); + await QueryaMotionController.instance.load(); + unawaited(UpdateController.instance.initialize()); + unawaited(cleanupStaleMongoTlsTempFiles()); + unawaited(McpServerController.instance.startIfEnabled()); + runApp(const QueryaApp()); + } catch (error, stack) { + debugPrint('Startup database initialization failed: $error\n$stack'); + runApp( + QueryaStartupRecoveryApp( + error: error, + stackTrace: stack, + ), + ); + } + doWhenWindowReady(() { final win = appWindow; win.minSize = const Size(900, 600); diff --git a/test/features/recovery/querya_startup_recovery_app_test.dart b/test/features/recovery/querya_startup_recovery_app_test.dart new file mode 100644 index 00000000..57fbad1c --- /dev/null +++ b/test/features/recovery/querya_startup_recovery_app_test.dart @@ -0,0 +1,135 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/app_data_root.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/recovery/querya_startup_recovery_app.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + @override + Future getApplicationDocumentsPath() async => _root; + @override + Future getTemporaryPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_recovery_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + AppDataRoot.resetMocks(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + group('LocalDb disaster recovery operations', () { + test('backupDatabaseFile creates a timestamped copy of querya.db', () async { + await LocalDb.instance.close(); + final dbPath = await LocalDb.instance.databasePath(); + final dbFile = File(dbPath); + await dbFile.parent.create(recursive: true); + await dbFile.writeAsString('fake sqlite header'); + + final backupPath = await LocalDb.instance.backupDatabaseFile(); + expect(await File(backupPath).exists(), isTrue); + expect(backupPath, contains('.corrupted.')); + expect(await File(backupPath).readAsString(), 'fake sqlite header'); + }); + + test('removeWalAndLockFiles deletes sidecar files', () async { + final dbPath = await LocalDb.instance.databasePath(); + final walFile = File('$dbPath-wal'); + final shmFile = File('$dbPath-shm'); + final lockFile = File('$dbPath-lock'); + + await walFile.writeAsString('wal data'); + await shmFile.writeAsString('shm data'); + await lockFile.writeAsString('lock data'); + + await LocalDb.instance.removeWalAndLockFiles(); + + expect(await walFile.exists(), isFalse); + expect(await shmFile.exists(), isFalse); + expect(await lockFile.exists(), isFalse); + }); + + test('resetDatabaseFile backs up and clears querya.db', () async { + final dbPath = await LocalDb.instance.databasePath(); + final dbFile = File(dbPath); + await dbFile.writeAsString('corrupted database body'); + + await LocalDb.instance.resetDatabaseFile(); + + expect(await dbFile.exists(), isFalse); + }); + }); + + group('QueryaStartupRecoveryApp widget', () { + testWidgets('renders recovery UI with error message and action buttons', + (tester) async { + var retried = false; + + await tester.pumpWidget( + QueryaStartupRecoveryApp( + error: 'SqliteException(5): database is locked', + onRetrySuccess: () { + retried = true; + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Database Recovery'), findsOneWidget); + expect(find.textContaining('SqliteException(5): database is locked'), + findsOneWidget); + expect(find.text('Retry Opening'), findsOneWidget); + expect(find.text('Unlock Database (Clear WAL)'), findsOneWidget); + expect(find.text('Export / Backup querya.db'), findsOneWidget); + expect(find.text('Start in Safe Mode'), findsOneWidget); + }); + + testWidgets('retry callback triggers when database can be opened', + (tester) async { + var retried = false; + + // Ensure clean db can be opened + await LocalDb.instance.close(); + final dbPath = await LocalDb.instance.databasePath(); + final dbFile = File(dbPath); + if (await dbFile.exists()) await dbFile.delete(); + + await tester.pumpWidget( + QueryaStartupRecoveryApp( + error: 'Transient startup lock error', + onRetrySuccess: () { + retried = true; + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Retry Opening')); + await tester.pumpAndSettle(); + + expect(retried, isTrue); + }); + }); +} From e61dd47d4979e5fc4d0d3324c4e9d6bd9b05edfc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 19:55:11 +0300 Subject: [PATCH 2/2] fix(storage): resolve analyzer issues in startup recovery UI - use ThemeController dark shadcn theme instead of nonexistent preset.data - add AppDataRoot.setSafeModeRootPath instead of writing a test-only field - remove unused imports and unused test variable - make static Text const --- lib/core/storage/app_data_root.dart | 6 ++++++ lib/features/recovery/querya_startup_recovery_app.dart | 8 +++----- .../recovery/querya_startup_recovery_app_test.dart | 10 +--------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/lib/core/storage/app_data_root.dart b/lib/core/storage/app_data_root.dart index c62cccab..c708f404 100644 --- a/lib/core/storage/app_data_root.dart +++ b/lib/core/storage/app_data_root.dart @@ -41,6 +41,12 @@ abstract final class AppDataRoot { @visibleForTesting static List? mockLegacySupportCandidates; + /// Points the portable data root at [path] for the rest of this session + /// (used by Safe Mode to run against a clean temporary profile). + static void setSafeModeRootPath(String path) { + mockPortableRootPath = path; + } + @visibleForTesting static void resetMocks() { mockPortableRootPath = null; diff --git a/lib/features/recovery/querya_startup_recovery_app.dart b/lib/features/recovery/querya_startup_recovery_app.dart index dc48c8f0..2555eada 100644 --- a/lib/features/recovery/querya_startup_recovery_app.dart +++ b/lib/features/recovery/querya_startup_recovery_app.dart @@ -1,11 +1,9 @@ import 'dart:io'; import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart'; import 'package:querya_desktop/app/app.dart'; import 'package:querya_desktop/core/storage/app_data_root.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -105,7 +103,7 @@ class _QueryaStartupRecoveryAppState try { await LocalDb.instance.close(); final tempDir = await Directory.systemTemp.createTemp('querya_safe_mode_'); - AppDataRoot.mockPortableRootPath = tempDir.path; + AppDataRoot.setSafeModeRootPath(tempDir.path); await LocalDb.instance.open(); _setStatus('Safe mode ready. Launching Querya...'); if (mounted) { @@ -124,7 +122,7 @@ class _QueryaStartupRecoveryAppState @override material.Widget build(material.BuildContext context) { - final themeData = QueryaThemePreset.queryaDark.data; + final themeData = ThemeController.instance.darkShadcnTheme; final colorScheme = themeData.colorScheme; return ShadcnApp( @@ -164,7 +162,7 @@ class _QueryaStartupRecoveryAppState ], ), const material.SizedBox(height: 16), - Text( + const Text( 'Querya could not open the profile database (querya.db) on startup. ' 'This can happen after a crash, concurrent lock, file permission conflict, ' 'or SQLite database corruption.', diff --git a/test/features/recovery/querya_startup_recovery_app_test.dart b/test/features/recovery/querya_startup_recovery_app_test.dart index 57fbad1c..48d018ba 100644 --- a/test/features/recovery/querya_startup_recovery_app_test.dart +++ b/test/features/recovery/querya_startup_recovery_app_test.dart @@ -1,13 +1,10 @@ import 'dart:io'; -import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/storage/app_data_root.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/recovery/querya_startup_recovery_app.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; class _FakePathProvider extends PathProviderPlatform { _FakePathProvider(this._root); @@ -85,14 +82,9 @@ void main() { group('QueryaStartupRecoveryApp widget', () { testWidgets('renders recovery UI with error message and action buttons', (tester) async { - var retried = false; - await tester.pumpWidget( - QueryaStartupRecoveryApp( + const QueryaStartupRecoveryApp( error: 'SqliteException(5): database is locked', - onRetrySuccess: () { - retried = true; - }, ), ); await tester.pumpAndSettle();