fix: clamp local datetime to valid SQLite range

This commit is contained in:
shenlong-tanwen
2026-07-23 20:40:59 +05:30
parent 20ae07e228
commit df8c1c37ce
4 changed files with 112 additions and 7 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
const int _maxMillisecondsSinceEpoch = 8640000000000000; // 275760-09-13
const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01
const int _maxMillisecondsSinceEpoch = 253402300799000; // 9999-12-31 (SQLite limit)
const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01 (Dart / SQLite limit)
DateTime? tryFromSecondsSinceEpoch(int? secondsSinceEpoch, {bool isUtc = false}) {
if (secondsSinceEpoch == null) {
+19 -1
View File
@@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/feature_message.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
@@ -20,7 +21,7 @@ import 'package:immich_mobile/infrastructure/repositories/settings.repository.da
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 26;
const int targetVersion = 27;
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int? storedVersion = Store.tryGet(StoreKey.version);
@@ -34,6 +35,10 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo26(drift);
}
if (version < 27) {
await migrateTo27(drift);
}
if (storedVersion == null) {
await FeatureMessageService(SettingsRepository.instance).markSeen();
}
@@ -145,6 +150,19 @@ Future<void> _migrateTo26(Drift drift) async {
await migrator.complete();
}
@visibleForTesting
Future<void> migrateTo27(Drift drift) async {
final now = DateTime.timestamp();
const format = '%Y-%m-%d';
final table = drift.localAssetEntity;
await (drift.update(
table,
)..where((t) => t.createdAt.strftime(format).isNull())).write(LocalAssetEntityCompanion(createdAt: .new(now)));
await (drift.update(
table,
)..where((t) => t.updatedAt.strftime(format).isNull())).write(LocalAssetEntityCompanion(updatedAt: .new(now)));
}
Future<void> _migrateAlbumSortMode(_StoreMigrator migrator) async {
final raw = await migrator.readLegacyStoreInt(StoreKey.legacySelectedAlbumSortOrder.id);
final mode = AlbumSortMode.values.firstWhereOrNull((e) => raw != null && e.storeIndex == raw);
+81
View File
@@ -0,0 +1,81 @@
import 'package:drift/drift.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
setUp(() {
ctx = MediumRepositoryContext();
});
tearDown(() async {
await ctx.dispose();
});
group('migrateTo27', () {
Future<bool> bucketDateIsNull(String column, String id) async {
final row = await ctx.db
.customSelect(
"SELECT STRFTIME('%Y-%m-%d', $column) AS date FROM local_asset_entity WHERE id = ?",
variables: [Variable<String>(id)],
)
.getSingle();
return row.readNullable<String>('date') == null;
}
test('resets a local asset whose year exceeds SQLite range', () async {
final asset = await ctx.newLocalAsset(createdAt: DateTime.utc(50000, 6, 15));
expect(
await bucketDateIsNull('created_at', asset.id),
isTrue,
reason: 'precondition: row must not be parseable by SQLite',
);
await migrateTo27(ctx.db);
final createdAtRow = await (ctx.db.localAssetEntity.select()..where((t) => t.id.equals(asset.id))).getSingle();
final createdAt = createdAtRow.createdAt;
expect(createdAt.year, lessThanOrEqualTo(9999));
expect(await bucketDateIsNull('created_at', asset.id), isFalse);
});
test('resets an out of range updated_at', () async {
final asset = await ctx.newLocalAsset(updatedAt: DateTime.utc(60000, 1, 1));
expect(await bucketDateIsNull('updated_at', asset.id), isTrue);
await migrateTo27(ctx.db);
expect(await bucketDateIsNull('updated_at', asset.id), isFalse);
});
test('valid datetime values are untouched', () async {
final original = DateTime.utc(2024, 1, 2, 3, 4, 5);
final asset = await ctx.newLocalAsset(createdAt: original);
await migrateTo27(ctx.db);
final createdAtRow = await (ctx.db.localAssetEntity.select()..where((t) => t.id.equals(asset.id))).getSingle();
expect(createdAtRow.createdAt, original);
});
test('the timeline bucket query crashes before but succeeds after the migration', () async {
final user = await ctx.newUser();
final album = await ctx.newLocalAlbum(backupSelection: .selected);
final asset = await ctx.newLocalAsset(createdAt: .utc(50000, 6, 15));
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final query = ctx.db.mergedAssetDrift.mergedBucket(userIds: [user.id], groupBy: 0);
await expectLater(query.get(), throwsA(isA<TypeError>()));
await migrateTo27(ctx.db);
final buckets = await query.get();
expect(buckets, hasLength(1));
expect(buckets.first.assetCount, 1);
});
});
}
@@ -16,8 +16,14 @@ void main() {
});
test('returns null for value above maximum allowed range', () {
// _maxMillisecondsSinceEpoch = 8640000000000000
final seconds = 8640000000000000 ~/ 1000 + 1; // One second after max allowed
// _maxMillisecondsSinceEpoch = 253402300799000
final seconds = 253402300799000 ~/ 1000 + 1; // One second after max allowed
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, isNull);
});
test('returns null for a year beyond SQLite range', () {
final seconds = 8640000000000000 ~/ 1000; // Year 275760
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, isNull);
});
@@ -29,9 +35,9 @@ void main() {
});
test('returns correct DateTime for maximum allowed value', () {
final seconds = 8640000000000000 ~/ 1000; // Maximum allowed timestamp
final seconds = 253402300799000 ~/ 1000; // Maximum allowed timestamp
final result = tryFromSecondsSinceEpoch(seconds);
expect(result, DateTime.fromMillisecondsSinceEpoch(8640000000000000));
expect(result, DateTime.fromMillisecondsSinceEpoch(253402300799000));
});
test('returns correct DateTime for negative timestamp', () {