This commit is contained in:
Santo Shakil 2026-08-15 12:41:08 +06:00 committed by GitHub
commit 0a123d762b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 4819 additions and 32 deletions

View file

@ -48,6 +48,7 @@ linter:
combinators_ordering: true
avoid_multiple_declarations_per_line: true
unnecessary_breaks: true
deprecated_member_use_from_same_package: true
# Correctness
no_adjacent_strings_in_list: true
@ -87,6 +88,7 @@ analyzer:
errors:
unawaited_futures: warning
always_put_control_body_on_new_line: warning
deprecated_member_use_from_same_package: error
dart_code_metrics:
rules:

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)')
@ -35,7 +36,7 @@ class AssetFaceEntity extends Table with DriftDefaultsMixin {
BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
DateTimeColumn get deletedAt => dateTime().nullable()();
DateTimeColumn get deletedAt => customType(clampedDateTime).nullable()();
@override
Set<Column> get primaryKey => {id};

View file

@ -1,5 +1,6 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class AuthUserEntity extends Table with DriftDefaultsMixin {
@ -12,7 +13,7 @@ class AuthUserEntity extends Table with DriftDefaultsMixin {
// Profile image
BoolColumn get hasProfileImage => boolean().withDefault(const Constant(false))();
DateTimeColumn get profileChangedAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get profileChangedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
IntColumn get avatarColor => intEnum<AvatarColor>()();
// Quota

View file

@ -2,6 +2,7 @@ import 'package:drift/drift.dart' hide Query;
import 'package:immich_mobile/domain/models/exif.model.dart' as domain;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
@ -21,7 +22,7 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin {
TextColumn get country => text().nullable()();
DateTimeColumn get dateTimeOriginal => dateTime().nullable()();
DateTimeColumn get dateTimeOriginal => customType(clampedDateTime).nullable()();
TextColumn get description => text().nullable()();

View file

@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class LocalAlbumEntity extends Table with DriftDefaultsMixin {
@ -9,7 +10,7 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin {
TextColumn get id => text()();
TextColumn get name => text()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
IntColumn get backupSelection => intEnum<BackupSelection>()();
BoolColumn get isIosSharedAlbum => boolean().withDefault(const Constant(false))();

View file

@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
@ -20,7 +21,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
TextColumn get iCloudId => text().nullable()();
DateTimeColumn get adjustmentTime => dateTime().nullable()();
DateTimeColumn get adjustmentTime => customType(clampedDateTime).nullable()();
RealColumn get latitude => real().nullable()();

View file

@ -1,18 +1,26 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/log.model.dart' as domain;
import 'package:immich_mobile/infrastructure/entities/log.entity.drift.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class LogMessageEntity extends Table {
class LogMessageEntity extends Table with DriftDefaultsMixin {
const LogMessageEntity();
@override
String get tableName => 'logger_messages';
@override
bool get isStrict => false;
@override
bool get withoutRowId => false;
IntColumn get id => integer().autoIncrement()();
TextColumn get message => text()();
TextColumn get details => text().nullable()();
IntColumn get level => intEnum<domain.LogLevel>()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get createdAt => customType(clampedDateTime)();
TextColumn get logger => text().nullable()();
TextColumn get stack => text().nullable()();
}

View file

@ -1,6 +1,7 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class MemoryEntity extends Table with DriftDefaultsMixin {
@ -8,11 +9,11 @@ class MemoryEntity extends Table with DriftDefaultsMixin {
TextColumn get id => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get createdAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
DateTimeColumn get deletedAt => dateTime().nullable()();
DateTimeColumn get deletedAt => customType(clampedDateTime).nullable()();
TextColumn get ownerId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
@ -22,13 +23,13 @@ class MemoryEntity extends Table with DriftDefaultsMixin {
BoolColumn get isSaved => boolean().withDefault(const Constant(false))();
DateTimeColumn get memoryAt => dateTime()();
DateTimeColumn get memoryAt => customType(clampedDateTime)();
DateTimeColumn get seenAt => dateTime().nullable()();
DateTimeColumn get seenAt => customType(clampedDateTime).nullable()();
DateTimeColumn get showAt => dateTime().nullable()();
DateTimeColumn get showAt => customType(clampedDateTime).nullable()();
DateTimeColumn get hideAt => dateTime().nullable()();
DateTimeColumn get hideAt => customType(clampedDateTime).nullable()();
@override
Set<Column> get primaryKey => {id};

View file

@ -1,5 +1,6 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)')
@ -8,9 +9,9 @@ class PersonEntity extends Table with DriftDefaultsMixin {
TextColumn get id => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get createdAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
TextColumn get ownerId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
@ -24,7 +25,7 @@ class PersonEntity extends Table with DriftDefaultsMixin {
TextColumn get color => text().nullable()();
DateTimeColumn get birthDate => dateTime().nullable()();
DateTimeColumn get birthDate => customType(clampedDateTime).nullable()();
@override
Set<Column> get primaryKey => {id};

View file

@ -1,6 +1,7 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class RemoteAlbumEntity extends Table with DriftDefaultsMixin {
@ -12,9 +13,9 @@ class RemoteAlbumEntity extends Table with DriftDefaultsMixin {
TextColumn get description => text().withDefault(const Constant(''))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get createdAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
TextColumn get thumbnailAssetId =>
text().references(RemoteAssetEntity, #id, onDelete: KeyAction.setNull).nullable()();

View file

@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('''
@ -33,13 +34,13 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
TextColumn get ownerId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
DateTimeColumn get localDateTime => dateTime().nullable()();
DateTimeColumn get localDateTime => customType(clampedDateTime).nullable()();
TextColumn get thumbHash => text().nullable()();
DateTimeColumn get deletedAt => dateTime().nullable()();
DateTimeColumn get deletedAt => customType(clampedDateTime).nullable()();
DateTimeColumn get uploadedAt => dateTime().nullable()();
DateTimeColumn get uploadedAt => customType(clampedDateTime).nullable()();
TextColumn get livePhotoVideoId => text().nullable()();

View file

@ -1,5 +1,6 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)')
@ -8,9 +9,9 @@ class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin {
TextColumn get cloudId => text().nullable()();
DateTimeColumn get createdAt => dateTime().nullable()();
DateTimeColumn get createdAt => customType(clampedDateTime).nullable()();
DateTimeColumn get adjustmentTime => dateTime().nullable()();
DateTimeColumn get adjustmentTime => customType(clampedDateTime).nullable()();
RealColumn get latitude => real().nullable()();

View file

@ -1,4 +1,5 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class SettingsEntity extends Table with DriftDefaultsMixin {
@ -8,7 +9,7 @@ class SettingsEntity extends Table with DriftDefaultsMixin {
TextColumn get value => text().nullable()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {key};

View file

@ -1,5 +1,6 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)')
@ -8,9 +9,9 @@ class StackEntity extends Table with DriftDefaultsMixin {
TextColumn get id => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get createdAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
TextColumn get ownerId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)();

View file

@ -1,5 +1,6 @@
import 'package:drift/drift.dart' hide Index;
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class UserEntity extends Table with DriftDefaultsMixin {
@ -11,7 +12,7 @@ class UserEntity extends Table with DriftDefaultsMixin {
// Profile image
BoolColumn get hasProfileImage => boolean().withDefault(const Constant(false))();
DateTimeColumn get profileChangedAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get profileChangedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
IntColumn get avatarColor => intEnum<AvatarColor>().withDefault(const Constant(0))();
@override

View file

@ -120,7 +120,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 31;
int get schemaVersion => 32;
@override
MigrationStrategy get migration => MigrationStrategy(
@ -318,6 +318,9 @@ class Drift extends $Drift {
from30To31: (m, v31) async {
await m.createIndex(v31.idxRemoteAssetUploaded);
},
from31To32: (m, v32) async {
await healOutOfRangeDateTimes(this);
},
),
),
);
@ -344,6 +347,45 @@ class Drift extends $Drift {
);
}
// every datetime column of the v31 schema, hardcoded: the heal runs once at v31->v32,
// so the set must not follow later schema changes
@visibleForTesting
const healDateTimeColumns = <String, List<String>>{
'auth_user_entity': ['profile_changed_at'],
'user_entity': ['profile_changed_at'],
'local_album_entity': ['updated_at'],
'local_asset_entity': ['created_at', 'updated_at', 'adjustment_time'],
'remote_asset_entity': ['created_at', 'updated_at', 'local_date_time', 'deleted_at', 'uploaded_at'],
'trashed_local_asset_entity': ['created_at', 'updated_at'],
'remote_exif_entity': ['date_time_original'],
'remote_album_entity': ['created_at', 'updated_at'],
'remote_asset_cloud_id_entity': ['created_at', 'adjustment_time'],
'memory_entity': ['created_at', 'updated_at', 'deleted_at', 'memory_at', 'seen_at', 'show_at', 'hide_at'],
'stack_entity': ['created_at', 'updated_at'],
'person_entity': ['created_at', 'updated_at', 'birth_date'],
'asset_face_entity': ['deleted_at'],
'settings': ['updated_at'],
};
// Rewrites datetime text sqlite date functions cannot handle: signed extended
// years and year 0000 (pre-clamp syncs), plus anything later than the safe
// midnight ceiling, which re-overflows sqlite under 'localtime' east of UTC.
// One statement per table: each column heals only when its own value is out of range
@visibleForTesting
Future<void> healOutOfRangeDateTimes(GeneratedDatabase db) async {
const floor = '0001-01-01T00:00:00.000Z';
const ceiling = '9999-12-31T00:00:00.000Z';
for (final MapEntry(key: table, value: columns) in healDateTimeColumns.entries) {
String low(String c) => "substr($c, 1, 1) = '-' OR substr($c, 1, 4) = '0000'";
String high(String c) => "substr($c, 1, 1) = '+' OR $c > '$ceiling'";
final assignments = columns.map(
(c) => "$c = CASE WHEN ${low(c)} THEN '$floor' WHEN ${high(c)} THEN '$ceiling' ELSE $c END",
);
final outOfRange = columns.map((c) => '${low(c)} OR ${high(c)}');
await db.customStatement('UPDATE $table SET ${assignments.join(', ')} WHERE ${outOfRange.join(' OR ')}');
}
}
class DriftDatabaseRepository {
final Drift _db;
const DriftDatabaseRepository(this._db);

View file

@ -16506,6 +16506,591 @@ final class Schema31 extends i0.VersionedSchema {
);
}
final class Schema32 extends i0.VersionedSchema {
Schema32({required super.database}) : super(version: 32);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
idxRemoteAssetUploaded,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
final i1.Index idxRemoteAssetUploaded = i1.Index(
'idx_remote_asset_uploaded',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_224, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 assetOcrEntity = Shape51(
source: i0.VersionedTable(
entityName: 'asset_ocr_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_213,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_201,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxAssetOcrAssetId = i1.Index(
'idx_asset_ocr_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
);
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -16537,6 +17122,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -16690,6 +17276,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from30To31(migrator, schema);
return 31;
case 31:
final schema = Schema32(database: database);
final migrator = i1.Migrator(database, schema);
await from31To32(migrator, schema);
return 32;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -16727,6 +17318,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@ -16759,5 +17351,6 @@ i1.OnUpgrade stepByStep({
from28To29: from28To29,
from29To30: from29To30,
from30To31: from30To31,
from31To32: from31To32,
),
);

View file

@ -1,11 +1,13 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
mixin AssetEntityMixin on Table {
mixin AssetEntityMixin on DriftDefaultsMixin {
TextColumn get name => text()();
IntColumn get type => intEnum<AssetType>()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get createdAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => customType(clampedDateTime).withDefault(currentDateAndTime)();
IntColumn get width => integer().nullable()();
IntColumn get height => integer().nullable()();
IntColumn get durationMs => integer().nullable()();

View file

@ -0,0 +1,41 @@
import 'package:drift/drift.dart';
// Synced dates can fall outside the year range sqlite date functions handle
// (1..9999), which turns bucket queries into NULLs and crashes the timeline
// (#28524). Clamps on write; the v32 heal migration rewrites the backlog.
const clampedDateTime = DateTimeClampType();
final class DateTimeClampType implements DialectAwareSqlType<DateTime> {
const DateTimeClampType();
@override
DateTime read(SqlTypes types, Object fromSql) => types.read(DriftSqlType.dateTime, fromSql)!;
@override
Object mapToSqlParameter(GenerationContext context, DateTime value) =>
context.typeMapping.mapToSqlVariable(_clampDateTime(value))!;
@override
String mapToSqlLiteral(GenerationContext context, DateTime value) =>
context.typeMapping.mapToSqlLiteral(_clampDateTime(value));
@override
String sqlTypeName(GenerationContext context) => DriftSqlType.dateTime.sqlTypeName(context);
}
// utc so drift's text mapping never formats these with a historical local
// offset (those carry seconds, which drift refuses to store). the ceiling
// stays at midnight: a later time would overflow sqlite's year range again
// when a query applies 'localtime' east of UTC
final DateTime _floor = DateTime.utc(1);
final DateTime _ceiling = DateTime.utc(9999, 12, 31);
DateTime _clampDateTime(DateTime value) {
if (value.isBefore(_floor)) {
return _floor;
}
if (value.isAfter(_ceiling)) {
return _ceiling;
}
return value;
}

View file

@ -1,6 +1,10 @@
import 'package:drift/drift.dart';
mixin DriftDefaultsMixin on Table {
@Deprecated('Use customType(clampedDateTime)')
@override
ColumnBuilder<DateTime> dateTime() => super.dateTime();
@override
bool get isStrict => true;

View file

@ -0,0 +1,52 @@
import 'package:drift/drift.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/utils/datetime_clamp.type.dart';
void main() {
const options = DriftDatabaseOptions(storeDateTimeAsText: true);
final types = options.createTypeMapping(SqlDialect.sqlite);
final ctx = GenerationContext(options, null);
DateTime toSql(DateTime value) => types.read(DriftSqlType.dateTime, clampedDateTime.mapToSqlParameter(ctx, value))!;
test('clamps far-future years to the ceiling on write', () {
// the reporter's date from #28524
final poison = DateTime.utc(144769, 11, 18, 12, 38, 32);
final ceiling = DateTime.utc(9999, 12, 31);
expect(toSql(poison), ceiling);
expect(toSql(DateTime(144769, 1, 1)), ceiling); // local poison normalizes to utc
});
test('clamps BCE and year zero to the floor on write', () {
final floor = DateTime.utc(1, 1, 1);
expect(toSql(DateTime.utc(-4712, 3, 4, 5, 6, 7)), floor);
expect(toSql(DateTime.utc(0, 12, 31)), floor);
});
test('keeps in-range years untouched, but clamps the last hours of 9999-12-31', () {
// the range-exact lesson: in-range values never change, even near the edges
final low = DateTime.utc(1, 1, 1);
final justBefore = DateTime.utc(9999, 12, 30, 23, 59, 59);
final normal = DateTime.utc(2024, 1, 2, 3, 4, 5, 123);
expect(toSql(low), low);
expect(toSql(justBefore), justBefore);
expect(toSql(normal), normal);
// local datetimes keep their zone flag
final local = DateTime(2024, 6, 15, 10, 30, 25);
expect(toSql(local), local);
expect(toSql(local).isUtc, isFalse);
});
test('clamps the late hours of 9999-12-31 to the midnight ceiling', () {
// legal upstream (year 9999 passes the server's rule), but east of UTC the
// bucket query's 'localtime' overflows sqlite's year range on it -> NULL
final ceiling = DateTime.utc(9999, 12, 31);
expect(toSql(DateTime.utc(9999, 12, 31, 23, 59, 59)), ceiling);
expect(toSql(DateTime(9999, 12, 31, 23, 59, 59)), ceiling); // local normalizes to utc
});
test('reads pass stored values through untouched', () {
// writes clamp and the v32 migration heals the backlog, so reads never clamp
final stored = types.mapToSqlVariable(DateTime.utc(2024, 1, 2, 3, 4, 5, 123))!;
expect(clampedDateTime.read(types, stored), DateTime.utc(2024, 1, 2, 3, 4, 5, 123));
});
}

View file

@ -0,0 +1,242 @@
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
setUp(() {
ctx = MediumRepositoryContext();
});
tearDown(() async {
await ctx.dispose();
});
final farFuture = DateTime.utc(144769, 11, 18, 12, 38, 32); // the reporter's year from #28524
final bce = DateTime.utc(-4712, 3, 4, 5, 6, 7);
const ceiling = '9999-12-31T00:00:00.000Z';
const floor = '0001-01-01T00:00:00.000Z';
test('clamps poison on every datetime column of every table (auto-covers future columns)', () async {
// one row builder + pk values per table with datetime columns, in
// allTables order (foreign keys resolve in this order). a date table
// missing here fails the walk, and a date column left unpoisoned fails
// the read-back, so schema growth forces this test to be extended
final builders =
<String, (Insertable<dynamic> Function(String suffix, DateTime d), List<String> Function(String suffix))>{
'auth_user_entity': (
(s, d) => AuthUserEntityCompanion(
id: .new('au1$s'),
name: const .new('n'),
email: const .new('e'),
avatarColor: const .new(AvatarColor.primary),
profileChangedAt: .new(d),
),
(s) => ['au1$s'],
),
'user_entity': (
(s, d) => UserEntityCompanion(
id: .new('u1$s'),
name: const .new('n'),
email: const .new('e'),
profileChangedAt: .new(d),
),
(s) => ['u1$s'],
),
'local_album_entity': (
(s, d) => LocalAlbumEntityCompanion(
id: .new('la1$s'),
name: const .new('n'),
updatedAt: .new(d),
backupSelection: const .new(BackupSelection.selected),
),
(s) => ['la1$s'],
),
'local_asset_entity': (
(s, d) => LocalAssetEntityCompanion(
id: .new('l1$s'),
name: const .new('n'),
type: const .new(AssetType.image),
createdAt: .new(d),
updatedAt: .new(d),
adjustmentTime: .new(d),
),
(s) => ['l1$s'],
),
'remote_asset_entity': (
(s, d) => RemoteAssetEntityCompanion(
id: .new('a1$s'),
name: const .new('n'),
type: const .new(AssetType.image),
checksum: .new('c$s'),
ownerId: .new('u1$s'),
visibility: const .new(AssetVisibility.timeline),
createdAt: .new(d),
updatedAt: .new(d),
localDateTime: .new(d),
deletedAt: .new(d),
uploadedAt: .new(d),
),
(s) => ['a1$s'],
),
'remote_exif_entity': (
(s, d) => RemoteExifEntityCompanion(assetId: .new('a1$s'), dateTimeOriginal: .new(d)),
(s) => ['a1$s'],
),
'remote_album_entity': (
(s, d) => RemoteAlbumEntityCompanion(
id: .new('ra1$s'),
name: const .new('n'),
order: const .new(AlbumAssetOrder.asc),
createdAt: .new(d),
updatedAt: .new(d),
),
(s) => ['ra1$s'],
),
'remote_asset_cloud_id_entity': (
(s, d) =>
RemoteAssetCloudIdEntityCompanion(assetId: .new('a1$s'), createdAt: .new(d), adjustmentTime: .new(d)),
(s) => ['a1$s'],
),
'memory_entity': (
(s, d) => MemoryEntityCompanion(
id: .new('m1$s'),
ownerId: .new('u1$s'),
type: const .new(MemoryTypeEnum.onThisDay),
data: const .new('{}'),
createdAt: .new(d),
updatedAt: .new(d),
deletedAt: .new(d),
memoryAt: .new(d),
seenAt: .new(d),
showAt: .new(d),
hideAt: .new(d),
),
(s) => ['m1$s'],
),
'stack_entity': (
(s, d) => StackEntityCompanion(
id: .new('s1$s'),
ownerId: .new('u1$s'),
primaryAssetId: .new('a1$s'),
createdAt: .new(d),
updatedAt: .new(d),
),
(s) => ['s1$s'],
),
'person_entity': (
(s, d) => PersonEntityCompanion(
id: .new('p1$s'),
ownerId: .new('u1$s'),
name: const .new('n'),
isFavorite: const .new(false),
isHidden: const .new(false),
createdAt: .new(d),
updatedAt: .new(d),
birthDate: .new(d),
),
(s) => ['p1$s'],
),
'asset_face_entity': (
(s, d) => AssetFaceEntityCompanion(
id: .new('f1$s'),
assetId: .new('a1$s'),
imageWidth: const .new(1),
imageHeight: const .new(1),
boundingBoxX1: const .new(0),
boundingBoxY1: const .new(0),
boundingBoxX2: const .new(1),
boundingBoxY2: const .new(1),
sourceType: const .new('machine-learning'),
deletedAt: .new(d),
),
(s) => ['f1$s'],
),
'trashed_local_asset_entity': (
(s, d) => TrashedLocalAssetEntityCompanion(
id: .new('t1$s'),
albumId: .new('al1$s'),
name: const .new('n'),
type: const .new(AssetType.image),
source: const .new(TrashOrigin.localSync),
createdAt: .new(d),
updatedAt: .new(d),
),
(s) => ['t1$s', 'al1$s'],
),
'settings': ((s, d) => SettingsEntityCompanion(key: .new('k1$s'), updatedAt: .new(d)), (s) => ['k1$s']),
};
String pkWhere(String table) => switch (table) {
'settings' => '"key" = ?',
'remote_exif_entity' || 'remote_asset_cloud_id_entity' => '"asset_id" = ?',
'trashed_local_asset_entity' => '"id" = ? AND "album_id" = ?',
_ => '"id" = ?',
};
var walked = 0;
for (final table in ctx.db.allTables) {
final dateColumns = [
for (final column in table.$columns)
if (column is GeneratedColumn<DateTime>) column.$name,
];
if (dateColumns.isEmpty) {
continue;
}
walked++;
final builder = builders[table.entityName];
expect(builder, isNotNull, reason: 'no poison row defined for ${table.entityName}');
for (final (poison, expected, suffix) in [(farFuture, ceiling, '_f'), (bce, floor, '_b')]) {
await ctx.db.into(table).insert(builder!.$1(suffix, poison));
final pkVars = [for (final value in builder.$2(suffix)) Variable(value)];
for (final column in dateColumns) {
final row = await ctx.db
.customSelect(
'SELECT "$column" AS v FROM "${table.entityName}" WHERE ${pkWhere(table.entityName)}',
variables: pkVars,
)
.getSingle();
expect(row.read<String>('v'), expected, reason: '${table.entityName}.$column');
}
}
}
expect(walked, 14, reason: 'every table with datetime columns must be walked');
});
test('a late hour on the last day of 9999 is stored at the midnight ceiling', () async {
final user = await ctx.newUser();
final asset = await ctx.newRemoteAsset(ownerId: user.id, createdAt: DateTime.utc(9999, 12, 31, 23, 59, 59));
final row = await ctx.db
.customSelect('SELECT created_at AS v FROM remote_asset_entity WHERE id = ?', variables: [Variable(asset.id)])
.getSingle();
expect(row.read<String>('v'), ceiling);
});
}

View file

@ -0,0 +1,112 @@
import 'package:drift/drift.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
setUp(() {
ctx = MediumRepositoryContext();
});
tearDown(() async {
await ctx.dispose();
});
Future<String?> rawStored(String column, String id) async {
final row = await ctx.db
.customSelect('SELECT $column AS v FROM remote_asset_entity WHERE id = ?', variables: [Variable(id)])
.getSingle();
return row.readNullable<String>('v');
}
group('healOutOfRangeDateTimes', () {
test('rewrites out-of-range stored text and leaves valid rows untouched', () async {
final user = await ctx.newUser();
final farFuture = await ctx.newRemoteAsset(ownerId: user.id);
final bce = await ctx.newRemoteAsset(ownerId: user.id);
final yearZero = await ctx.newRemoteAsset(ownerId: user.id);
final valid = await ctx.newRemoteAsset(ownerId: user.id, createdAt: DateTime.utc(2024, 1, 2, 3, 4, 5, 123));
// raw sql is the only way poison can exist now: the drift api clamps on write
await ctx.db.customStatement(
"UPDATE remote_asset_entity SET created_at = '+144769-11-18T12:38:32.000Z', local_date_time = '+144769-11-18T18:38:32.000 +06:00' WHERE id = ?",
[farFuture.id],
);
await ctx.db.customStatement(
"UPDATE remote_asset_entity SET created_at = '-004712-03-04T05:06:07.000Z' WHERE id = ?",
[bce.id],
);
await ctx.db.customStatement(
"UPDATE remote_asset_entity SET created_at = '0000-06-15T10:30:00.000Z' WHERE id = ?",
[yearZero.id],
);
await healOutOfRangeDateTimes(ctx.db);
expect(await rawStored('created_at', farFuture.id), '9999-12-31T00:00:00.000Z');
expect(await rawStored('local_date_time', farFuture.id), '9999-12-31T00:00:00.000Z');
expect(await rawStored('created_at', bce.id), '0001-01-01T00:00:00.000Z');
expect(await rawStored('created_at', yearZero.id), '0001-01-01T00:00:00.000Z');
expect(await rawStored('created_at', valid.id), '2024-01-02T03:04:05.123Z');
});
test('heals a stored late-9999 value the year predicate missed', () async {
final user = await ctx.newUser();
final asset = await ctx.newRemoteAsset(ownerId: user.id);
// legal by the server's rule, but re-overflows sqlite under 'localtime' east of UTC
await ctx.db.customStatement(
"UPDATE remote_asset_entity SET created_at = '9999-12-31T23:59:59.000Z' WHERE id = ?",
[asset.id],
);
await healOutOfRangeDateTimes(ctx.db);
expect(await rawStored('created_at', asset.id), '9999-12-31T00:00:00.000Z');
// the healed value itself is not rewritten again
await healOutOfRangeDateTimes(ctx.db);
expect(await rawStored('created_at', asset.id), '9999-12-31T00:00:00.000Z');
});
test('the merged bucket query crashes before and succeeds after the heal', () async {
final user = await ctx.newUser();
final album = await ctx.newLocalAlbum(backupSelection: .selected);
final asset = await ctx.newLocalAsset(createdAt: DateTime.utc(2024, 1, 2));
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
await ctx.db.customStatement(
"UPDATE local_asset_entity SET created_at = '+144769-11-18T12:38:32.000Z' WHERE id = ?",
[asset.id],
);
// the #28559 class: the native merged bucket reads the NULL bucket date
final query = ctx.db.mergedAssetDrift.mergedBucket(userIds: [user.id], groupBy: 0);
await expectLater(query.get(), throwsA(isA<TypeError>()));
await healOutOfRangeDateTimes(ctx.db);
final buckets = await query.get();
expect(buckets, hasLength(1));
expect(buckets.first.assetCount, 1);
expect(buckets.first.bucketDate, '9999-12-31');
});
test('stores datetimes byte-identical to drift text mapping', () async {
final user = await ctx.newUser();
final remote = await ctx.newRemoteAsset(
ownerId: user.id,
createdAt: DateTime.utc(2024, 1, 2, 3, 4, 5, 123),
updatedAt: DateTime.utc(2024, 2, 3, 4, 5, 6, 456),
);
expect(await rawStored('created_at', remote.id), '2024-01-02T03:04:05.123Z');
expect(await rawStored('updated_at', remote.id), '2024-02-03T04:05:06.456Z');
final local = DateTime(2024, 6, 15, 10, 30, 25);
final asset = await ctx.newLocalAsset(createdAt: local);
final row = await (ctx.db.select(ctx.db.localAssetEntity)..where((t) => t.id.equals(asset.id))).getSingle();
expect(row.createdAt, local);
});
});
}

View file

@ -1,5 +1,9 @@
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:intl/date_symbol_data_local.dart';
@ -146,4 +150,50 @@ void main() {
expect(buckets.single.assetCount, 1);
});
});
group('dates sqlite cannot format', () {
// Regression check for #28524: out-of-range stored dates are healed by the
// migration, so bucket queries never read a NULL bucket date
test('archive returns a sane bucket once the poisoned date is healed', () async {
final user = await ctx.newUser();
final asset = await ctx.newRemoteAsset(ownerId: user.id, visibility: .archive);
await ctx.db.customStatement(
"UPDATE remote_asset_entity SET created_at = '+144769-11-18T12:38:32.000Z', local_date_time = '+144769-11-18T18:38:32.000 +06:00' WHERE id = ?",
[asset.id],
);
final query = sut.archived(user.id, .day);
await expectLater(query.bucketSource().first, throwsA(isA<TypeError>()));
await healOutOfRangeDateTimes(ctx.db);
expect(await query.bucketSource().first, [TimeBucket(date: DateTime(9999, 12, 31), assetCount: 1)]);
final assets = await query.assetSource(0, 10);
expect(assets, hasLength(1));
expect((assets.single as RemoteAsset).id, asset.id);
});
test('a late-9999 date is clamped on write, so the bucket query stays safe', () async {
// local_date_time null -> the bucket coalesce falls to created_at with
// 'localtime', which overflows sqlite on the unclamped value east of UTC.
// the converter writes the midnight ceiling instead (sqlite probe receipt)
final user = await ctx.newUser();
await ctx.db.remoteAssetEntity.insertOne(
RemoteAssetEntityCompanion.insert(
id: 'late1',
name: 'late1.jpg',
type: AssetType.image,
checksum: 'ck_late1',
ownerId: user.id,
visibility: AssetVisibility.archive,
createdAt: Value(DateTime.utc(9999, 12, 31, 23, 59, 59)),
localDateTime: const Value(null),
),
);
final query = sut.archived(user.id, .day);
expect(await query.bucketSource().first, [TimeBucket(date: DateTime(9999, 12, 31), assetCount: 1)]);
});
});
}