Skip to content

Commit 4893eb7

Browse files
Merge pull request #1382 from QueryaHub/issue/1375-localdb-disaster-recovery-ui
fix(storage): add disaster recovery fallback UI when LocalDb fails to open on startup
2 parents fcd2dc9 + e61dd47 commit 4893eb7

5 files changed

Lines changed: 477 additions & 8 deletions

File tree

‎lib/core/storage/app_data_root.dart‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ abstract final class AppDataRoot {
4141
@visibleForTesting
4242
static List<Directory>? mockLegacySupportCandidates;
4343

44+
/// Points the portable data root at [path] for the rest of this session
45+
/// (used by Safe Mode to run against a clean temporary profile).
46+
static void setSafeModeRootPath(String path) {
47+
mockPortableRootPath = path;
48+
}
49+
4450
@visibleForTesting
4551
static void resetMocks() {
4652
mockPortableRootPath = null;

‎lib/core/storage/local_db.dart‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,82 @@ class LocalDb {
878878
await db.delete('connections', where: 'id = ?', whereArgs: [id]);
879879
}
880880

881+
/// Resolves the absolute path to `querya.db`.
882+
Future<String> databasePath() async {
883+
if (_cachedDbPath != null) return _cachedDbPath!;
884+
final dir = await AppDataRoot.applicationSupportDirectory();
885+
final sub = Directory(p.join(dir.path, 'querya_desktop'));
886+
if (!await sub.exists()) await sub.create(recursive: true);
887+
_cachedDbPath = p.join(sub.path, _dbName);
888+
return _cachedDbPath!;
889+
}
890+
891+
/// Explicitly opens the database or throws if opening/migrating fails.
892+
Future<Database> open() => _open();
893+
894+
/// Removes stale WAL, SHM, and lock sidecars (e.g. after a process crash or freeze).
895+
Future<void> removeWalAndLockFiles() async {
896+
await close();
897+
final dbPath = await databasePath();
898+
final walFile = File('$dbPath-wal');
899+
final shmFile = File('$dbPath-shm');
900+
final lockFile = File('$dbPath-lock');
901+
if (await walFile.exists()) {
902+
try {
903+
await walFile.delete();
904+
} catch (_) {}
905+
}
906+
if (await shmFile.exists()) {
907+
try {
908+
await shmFile.delete();
909+
} catch (_) {}
910+
}
911+
if (await lockFile.exists()) {
912+
try {
913+
await lockFile.delete();
914+
} catch (_) {}
915+
}
916+
}
917+
918+
/// Creates a timestamped backup copy of `querya.db` (and any sidecar files)
919+
/// in the same directory and returns the backup file path.
920+
Future<String> backupDatabaseFile() async {
921+
await close();
922+
final dbPath = await databasePath();
923+
final dbFile = File(dbPath);
924+
if (!await dbFile.exists()) {
925+
throw FileSystemException('Database file does not exist', dbPath);
926+
}
927+
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-');
928+
final backupPath = '$dbPath.corrupted.$timestamp.bak';
929+
await dbFile.copy(backupPath);
930+
931+
final walFile = File('$dbPath-wal');
932+
if (await walFile.exists()) {
933+
try {
934+
await walFile.copy('$backupPath-wal');
935+
} catch (_) {}
936+
}
937+
return backupPath;
938+
}
939+
940+
/// Resets the database file by creating a backup and removing the corrupted db
941+
/// so a fresh schema can be created on the next launch.
942+
Future<void> resetDatabaseFile() async {
943+
await close();
944+
final dbPath = await databasePath();
945+
final dbFile = File(dbPath);
946+
if (await dbFile.exists()) {
947+
try {
948+
await backupDatabaseFile();
949+
} catch (_) {}
950+
try {
951+
await dbFile.delete();
952+
} catch (_) {}
953+
}
954+
await removeWalAndLockFiles();
955+
}
956+
881957
Future<void> close() async {
882958
_openFuture = null;
883959
await _db?.close();
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import 'dart:io';
2+
3+
import 'package:flutter/material.dart' as material;
4+
import 'package:querya_desktop/app/app.dart';
5+
import 'package:querya_desktop/core/storage/app_data_root.dart';
6+
import 'package:querya_desktop/core/storage/local_db.dart';
7+
import 'package:querya_desktop/core/theme/theme_controller.dart';
8+
import 'package:querya_desktop/core/ui/querya_icons.dart';
9+
import 'package:querya_desktop/shared/widgets/widgets.dart';
10+
11+
/// Disaster recovery UI launched when LocalDb fails to open or migrate on startup.
12+
/// Allows the user to inspect the error, retry, remove stale locks, backup the database,
13+
/// or launch in Safe Mode.
14+
class QueryaStartupRecoveryApp extends material.StatefulWidget {
15+
const QueryaStartupRecoveryApp({
16+
super.key,
17+
required this.error,
18+
this.stackTrace,
19+
this.onRetrySuccess,
20+
});
21+
22+
final Object error;
23+
final StackTrace? stackTrace;
24+
final material.VoidCallback? onRetrySuccess;
25+
26+
@override
27+
material.State<QueryaStartupRecoveryApp> createState() =>
28+
_QueryaStartupRecoveryAppState();
29+
}
30+
31+
class _QueryaStartupRecoveryAppState
32+
extends material.State<QueryaStartupRecoveryApp> {
33+
bool _isBusy = false;
34+
String? _statusMessage;
35+
bool _isErrorStatus = false;
36+
37+
void _setStatus(String message, {bool isError = false}) {
38+
setState(() {
39+
_statusMessage = message;
40+
_isErrorStatus = isError;
41+
});
42+
}
43+
44+
Future<void> _handleRetry() async {
45+
setState(() => _isBusy = true);
46+
_setStatus('Attempting to open database...');
47+
try {
48+
await LocalDb.instance.open();
49+
_setStatus('Database opened successfully!');
50+
if (mounted) {
51+
if (widget.onRetrySuccess != null) {
52+
widget.onRetrySuccess!();
53+
} else {
54+
material.runApp(const QueryaApp());
55+
}
56+
}
57+
} catch (e) {
58+
_setStatus('Retry failed: $e', isError: true);
59+
} finally {
60+
if (mounted) setState(() => _isBusy = false);
61+
}
62+
}
63+
64+
Future<void> _handleUnlockWal() async {
65+
setState(() => _isBusy = true);
66+
_setStatus('Removing WAL and lock files...');
67+
try {
68+
await LocalDb.instance.removeWalAndLockFiles();
69+
_setStatus('Stale lock and WAL files removed. Testing database connection...');
70+
await LocalDb.instance.open();
71+
_setStatus('Database opened successfully!');
72+
if (mounted) {
73+
if (widget.onRetrySuccess != null) {
74+
widget.onRetrySuccess!();
75+
} else {
76+
material.runApp(const QueryaApp());
77+
}
78+
}
79+
} catch (e) {
80+
_setStatus('Unlock attempted, but database failed to open: $e',
81+
isError: true);
82+
} finally {
83+
if (mounted) setState(() => _isBusy = false);
84+
}
85+
}
86+
87+
Future<void> _handleBackup() async {
88+
setState(() => _isBusy = true);
89+
_setStatus('Creating timestamped backup...');
90+
try {
91+
final backupPath = await LocalDb.instance.backupDatabaseFile();
92+
_setStatus('Backup saved to: $backupPath');
93+
} catch (e) {
94+
_setStatus('Backup failed: $e', isError: true);
95+
} finally {
96+
if (mounted) setState(() => _isBusy = false);
97+
}
98+
}
99+
100+
Future<void> _handleSafeMode() async {
101+
setState(() => _isBusy = true);
102+
_setStatus('Switching to clean safe profile...');
103+
try {
104+
await LocalDb.instance.close();
105+
final tempDir = await Directory.systemTemp.createTemp('querya_safe_mode_');
106+
AppDataRoot.setSafeModeRootPath(tempDir.path);
107+
await LocalDb.instance.open();
108+
_setStatus('Safe mode ready. Launching Querya...');
109+
if (mounted) {
110+
if (widget.onRetrySuccess != null) {
111+
widget.onRetrySuccess!();
112+
} else {
113+
material.runApp(const QueryaApp());
114+
}
115+
}
116+
} catch (e) {
117+
_setStatus('Failed to enter safe mode: $e', isError: true);
118+
} finally {
119+
if (mounted) setState(() => _isBusy = false);
120+
}
121+
}
122+
123+
@override
124+
material.Widget build(material.BuildContext context) {
125+
final themeData = ThemeController.instance.darkShadcnTheme;
126+
final colorScheme = themeData.colorScheme;
127+
128+
return ShadcnApp(
129+
title: 'Querya - Startup Recovery',
130+
theme: ThemeController.instance.darkShadcnTheme,
131+
darkTheme: ThemeController.instance.darkShadcnTheme,
132+
themeMode: ThemeMode.dark,
133+
debugShowCheckedModeBanner: false,
134+
home: material.Scaffold(
135+
backgroundColor: colorScheme.background,
136+
body: material.Center(
137+
child: material.SingleChildScrollView(
138+
padding: const material.EdgeInsets.all(32),
139+
child: material.ConstrainedBox(
140+
constraints: const material.BoxConstraints(maxWidth: 640),
141+
child: QueryaDialogCard(
142+
borderColor: colorScheme.destructive,
143+
child: material.Padding(
144+
padding: const material.EdgeInsets.all(28),
145+
child: material.Column(
146+
mainAxisSize: material.MainAxisSize.min,
147+
crossAxisAlignment: material.CrossAxisAlignment.start,
148+
children: [
149+
material.Row(
150+
children: [
151+
material.Icon(
152+
QueryaIcons.treeError,
153+
size: 32,
154+
color: colorScheme.destructive,
155+
),
156+
const material.SizedBox(width: 12),
157+
const material.Expanded(
158+
child: Text(
159+
'Database Recovery',
160+
),
161+
),
162+
],
163+
),
164+
const material.SizedBox(height: 16),
165+
const Text(
166+
'Querya could not open the profile database (querya.db) on startup. '
167+
'This can happen after a crash, concurrent lock, file permission conflict, '
168+
'or SQLite database corruption.',
169+
).muted(),
170+
const material.SizedBox(height: 16),
171+
material.Container(
172+
width: double.infinity,
173+
padding: const material.EdgeInsets.all(12),
174+
decoration: material.BoxDecoration(
175+
color: colorScheme.muted.withOpacity(0.3),
176+
borderRadius: material.BorderRadius.circular(6),
177+
border: material.Border.all(
178+
color: colorScheme.border,
179+
),
180+
),
181+
child: material.SelectableText(
182+
widget.error.toString(),
183+
style: const material.TextStyle(
184+
fontFamily: 'monospace',
185+
fontSize: 12,
186+
),
187+
),
188+
),
189+
if (_statusMessage != null) ...[
190+
const material.SizedBox(height: 16),
191+
material.Container(
192+
width: double.infinity,
193+
padding: const material.EdgeInsets.all(10),
194+
decoration: material.BoxDecoration(
195+
color: (_isErrorStatus
196+
? colorScheme.destructive
197+
: colorScheme.primary)
198+
.withOpacity(0.15),
199+
borderRadius: material.BorderRadius.circular(6),
200+
),
201+
child: Text(
202+
_statusMessage!,
203+
style: material.TextStyle(
204+
color: _isErrorStatus
205+
? colorScheme.destructive
206+
: colorScheme.primary,
207+
fontSize: 13,
208+
fontWeight: material.FontWeight.w500,
209+
),
210+
),
211+
),
212+
],
213+
const material.SizedBox(height: 24),
214+
material.Wrap(
215+
spacing: 12,
216+
runSpacing: 12,
217+
children: [
218+
PrimaryButton(
219+
onPressed: _isBusy ? null : _handleRetry,
220+
child: const Text('Retry Opening'),
221+
),
222+
OutlineButton(
223+
onPressed: _isBusy ? null : _handleUnlockWal,
224+
child: const Text('Unlock Database (Clear WAL)'),
225+
),
226+
OutlineButton(
227+
onPressed: _isBusy ? null : _handleBackup,
228+
child: const Text('Export / Backup querya.db'),
229+
),
230+
GhostButton(
231+
onPressed: _isBusy ? null : _handleSafeMode,
232+
child: const Text('Start in Safe Mode'),
233+
),
234+
],
235+
),
236+
],
237+
),
238+
),
239+
),
240+
),
241+
),
242+
),
243+
),
244+
);
245+
}
246+
}

‎lib/main.dart‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import 'core/platform/file_launch_service.dart';
1515
import 'core/security/ssl_certificate_support.dart';
1616
import 'core/storage/local_db.dart';
1717
import 'core/theme/theme_controller.dart';
18+
import 'features/recovery/querya_startup_recovery_app.dart';
1819
import 'features/updater/update_controller.dart';
1920

2021
void main([List<String> args = const []]) async {
@@ -30,14 +31,27 @@ void main([List<String> args = const []]) async {
3031

3132
DisplayRefreshService.initialize();
3233
await LocalDb.initFfi();
33-
await SyntaxHighlightService.ensureInitialized();
34-
await ThemeController.instance.load();
35-
await UiScaleController.instance.load();
36-
await QueryaMotionController.instance.load();
37-
unawaited(UpdateController.instance.initialize());
38-
unawaited(cleanupStaleMongoTlsTempFiles());
39-
unawaited(McpServerController.instance.startIfEnabled());
40-
runApp(const QueryaApp());
34+
35+
try {
36+
await LocalDb.instance.open();
37+
await SyntaxHighlightService.ensureInitialized();
38+
await ThemeController.instance.load();
39+
await UiScaleController.instance.load();
40+
await QueryaMotionController.instance.load();
41+
unawaited(UpdateController.instance.initialize());
42+
unawaited(cleanupStaleMongoTlsTempFiles());
43+
unawaited(McpServerController.instance.startIfEnabled());
44+
runApp(const QueryaApp());
45+
} catch (error, stack) {
46+
debugPrint('Startup database initialization failed: $error\n$stack');
47+
runApp(
48+
QueryaStartupRecoveryApp(
49+
error: error,
50+
stackTrace: stack,
51+
),
52+
);
53+
}
54+
4155
doWhenWindowReady(() {
4256
final win = appWindow;
4357
win.minSize = const Size(900, 600);

0 commit comments

Comments
 (0)