Skip to content

Commit 07ecc7b

Browse files
committed
feat: add schema version-mismatch guard to prevent older app from opening newer database
Reads PRAGMA user_version via raw sqlite3 in read-only mode before Drift opens the database. If the stored version exceeds the app's schema version, throws DatabaseVersionMismatchException and shows an "Update Required" screen instead of crashing on missing columns.
1 parent f896ca9 commit 07ecc7b

4 files changed

Lines changed: 101 additions & 1 deletion

File tree

lib/core/database/database.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1178,8 +1178,12 @@ class ScheduledNotifications extends Table {
11781178
class AppDatabase extends _$AppDatabase {
11791179
AppDatabase(super.e);
11801180

1181+
/// The current schema version as a static constant so that pre-open checks
1182+
/// (e.g. version-mismatch guard) can reference it without an instance.
1183+
static const int currentSchemaVersion = 49;
1184+
11811185
@override
1182-
int get schemaVersion => 49;
1186+
int get schemaVersion => currentSchemaVersion;
11831187

11841188
@override
11851189
MigrationStrategy get migration {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/// Thrown when the database file was created by a newer version of
2+
/// Submersion than the currently running app.
3+
///
4+
/// This prevents an older app from silently corrupting a newer schema
5+
/// by running stale migrations or downgrading the version stamp.
6+
class DatabaseVersionMismatchException implements Exception {
7+
final int databaseVersion;
8+
final int appVersion;
9+
10+
const DatabaseVersionMismatchException({
11+
required this.databaseVersion,
12+
required this.appVersion,
13+
});
14+
15+
@override
16+
String toString() =>
17+
'DatabaseVersionMismatchException: database is schema v$databaseVersion '
18+
'but this app only supports up to v$appVersion. '
19+
'Please update Submersion to the latest version.';
20+
}

lib/core/services/database_service.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import 'package:drift/native.dart';
44
import 'package:flutter/foundation.dart' show visibleForTesting;
55
import 'package:path_provider/path_provider.dart';
66
import 'package:path/path.dart' as p;
7+
import 'package:sqlite3/sqlite3.dart' as sqlite3;
78

89
import 'package:submersion/core/database/database.dart';
10+
import 'package:submersion/core/database/database_version_exception.dart';
911
import 'package:submersion/core/services/database_location_service.dart';
1012

1113
class DatabaseService {
@@ -86,6 +88,9 @@ class DatabaseService {
8688
await dbDir.create(recursive: true);
8789
}
8890

91+
// Guard: reject databases created by a newer version of the app
92+
_assertSchemaVersionCompatible(dbPath);
93+
8994
final file = File(dbPath);
9095
// Use synchronous NativeDatabase instead of createInBackground
9196
// Background isolates can cause close() to hang indefinitely during migration
@@ -105,6 +110,9 @@ class DatabaseService {
105110
await dbDir.create(recursive: true);
106111
}
107112

113+
// Guard: reject databases created by a newer version of the app
114+
_assertSchemaVersionCompatible(newPath);
115+
108116
// Small delay to ensure any previous database connections are fully released
109117
// This helps prevent SQLite file locking issues, especially with WAL mode
110118
await Future.delayed(const Duration(milliseconds: 100));
@@ -147,6 +155,33 @@ class DatabaseService {
147155
}
148156
}
149157

158+
/// Throws [DatabaseVersionMismatchException] if the database file's schema
159+
/// version is newer than what this build of the app supports.
160+
///
161+
/// Uses raw sqlite3 to read PRAGMA user_version before Drift opens the
162+
/// database, ensuring the file is never modified by a stale migration.
163+
void _assertSchemaVersionCompatible(String dbPath) {
164+
final file = File(dbPath);
165+
if (!file.existsSync()) return;
166+
167+
final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readOnly);
168+
try {
169+
final result = db.select('PRAGMA user_version');
170+
if (result.isEmpty) return;
171+
172+
final storedVersion = result.first.values.first;
173+
if (storedVersion is int &&
174+
storedVersion > AppDatabase.currentSchemaVersion) {
175+
throw DatabaseVersionMismatchException(
176+
databaseVersion: storedVersion,
177+
appVersion: AppDatabase.currentSchemaVersion,
178+
);
179+
}
180+
} finally {
181+
db.dispose();
182+
}
183+
}
184+
150185
/// Resolve the database path using location service or default
151186
Future<String> _resolveDatabasePath() async {
152187
if (_locationService != null) {

lib/main.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:submersion/core/providers/provider.dart';
55
import 'package:shared_preferences/shared_preferences.dart';
66

77
import 'package:submersion/app.dart';
8+
import 'package:submersion/core/database/database_version_exception.dart';
89
import 'package:submersion/core/domain/entities/storage_config.dart';
910
import 'package:submersion/core/services/database_location_service.dart';
1011
import 'package:submersion/core/services/database_service.dart';
@@ -105,6 +106,46 @@ Future<void> main() async {
105106
await speciesRepository.seedBuiltInSpecies();
106107

107108
runApp(SubmersionRestart(prefs: prefs));
109+
} on DatabaseVersionMismatchException catch (e) {
110+
debugPrint('FATAL: $e');
111+
runApp(
112+
MaterialApp(
113+
home: Scaffold(
114+
body: SafeArea(
115+
child: Padding(
116+
padding: const EdgeInsets.all(24),
117+
child: Column(
118+
mainAxisAlignment: MainAxisAlignment.center,
119+
children: [
120+
const Icon(Icons.update, size: 64, color: Colors.orange),
121+
const SizedBox(height: 24),
122+
const Text(
123+
'Update Required',
124+
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
125+
textAlign: TextAlign.center,
126+
),
127+
const SizedBox(height: 16),
128+
Text(
129+
'Your dive data was saved by a newer version of '
130+
'Submersion (schema v${e.databaseVersion}). This version '
131+
'only supports up to schema v${e.appVersion}.',
132+
style: const TextStyle(fontSize: 14),
133+
textAlign: TextAlign.center,
134+
),
135+
const SizedBox(height: 16),
136+
const Text(
137+
'Please update Submersion to the latest version. '
138+
'Your data is safe and has not been modified.',
139+
style: TextStyle(fontSize: 14),
140+
textAlign: TextAlign.center,
141+
),
142+
],
143+
),
144+
),
145+
),
146+
),
147+
),
148+
);
108149
} catch (e, stack) {
109150
debugPrint('FATAL: App initialization failed: $e');
110151
debugPrint('$stack');

0 commit comments

Comments
 (0)