From 071ae8e1c2a8beac24a4e7ebfd5ba509de29e41f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:38:57 +0200 Subject: [PATCH] refactor(database): drop the never-read connections.isDefault and api_cache.cached_at columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connections.isDefault was maintained by three write paths (upsert preservation, remove-promotion, setDefault) that no production code ever read back; api_cache.cached_at was documented 'optional future use' and written on every cache store. Both columns are dropped in a v21 migration with row-preservation coverage; the dead write machinery, recordAuthSuccess, updateSyncRuleLastExecuted, getDownloadOwnerCount, and OfflineActionType.fromId (rejection semantics live in the sync service's explicit switch) go with them. Recovery-image compatibility: committed tvOS snapshots written by older builds still carry isDefault in connections rows, and the restore decoder deliberately rejects rows that do not round-trip exactly. A retired-columns allowlist now strips such keys before the strict check, so pre-retirement images stay restorable — the legacy-plaintext restore test caught this and now pins it. _bindServerStatusListener in main.dart also loses its unused provider param and resolver indirection. --- lib/connection/connection_registry.dart | 66 +------- lib/database/app_database.dart | 49 +++--- lib/database/app_database.g.dart | 159 ++---------------- lib/database/download_operations.dart | 8 +- lib/database/tables.dart | 7 - lib/main.dart | 6 +- test/connection/connection_registry_test.dart | 61 +------ test/database/app_database_test.dart | 71 +++++++- test/database/download_operations_test.dart | 6 +- .../database/tvos_database_recovery_test.dart | 39 ++--- 10 files changed, 149 insertions(+), 323 deletions(-) diff --git a/lib/connection/connection_registry.dart b/lib/connection/connection_registry.dart index 2ff0bdead..0d8adec36 100644 --- a/lib/connection/connection_registry.dart +++ b/lib/connection/connection_registry.dart @@ -12,9 +12,6 @@ import 'connection.dart'; /// CRUD over the persisted [Connections] table. The registry is the source /// of truth for which connections the user has added; the runtime /// `MultiServerManager` populates per-server clients from these records. -/// -/// Single-connection users get a default automatically — power users with -/// multiple connections can override it via [setDefault]. class ConnectionRegistry { ConnectionRegistry(this._db); @@ -41,33 +38,19 @@ class ConnectionRegistry { return _rowToConnection(row); } - /// Insert or replace [connection]. If this is the first stored connection - /// it is automatically marked default; re-upserting an existing row keeps - /// the row's current `isDefault` and `createdAt` (so token/metadata - /// refreshes don't clear the default flag or restamp creation order). + /// Insert or replace [connection]. Re-upserting an existing row keeps the + /// row's current `createdAt` (so token/metadata refreshes don't restamp + /// creation order). /// /// Creation order is behaviour, not bookkeeping: it decides which - /// connection lends a profile its picture, and `remove` promotes the oldest - /// remaining row to default. Re-authenticating rebuilds the model with - /// `DateTime.now()` and reuses the same stable id, so without this the - /// originally-first connection would jump to last on every re-sign-in. + /// connection lends a profile its picture. Re-authenticating rebuilds the + /// model with `DateTime.now()` and reuses the same stable id, so without + /// this the originally-first connection would jump to last on every + /// re-sign-in. Future upsert(Connection connection) async { await _db.runIdentityMutation(() async { final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull(); - final bool isDefault; - final int createdAt; - if (existing != null) { - isDefault = existing.isDefault; - createdAt = existing.createdAt; - } else { - final any = - await (_db.selectOnly(_db.connections) - ..addColumns([_db.connections.id]) - ..limit(1)) - .getSingleOrNull(); - isDefault = any == null; - createdAt = connection.createdAt.millisecondsSinceEpoch; - } + final createdAt = existing?.createdAt ?? connection.createdAt.millisecondsSinceEpoch; final protectedConfig = await CredentialVault.protectConnectionConfig( connection.kind.id, connection.toConfigJson(), @@ -77,7 +60,6 @@ class ConnectionRegistry { kind: Value(connection.kind.id), displayName: Value(connection.displayName), configJson: Value(jsonEncode(protectedConfig)), - isDefault: Value(isDefault), createdAt: Value(createdAt), lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch), ); @@ -86,44 +68,14 @@ class ConnectionRegistry { appLogger.d('ConnectionRegistry: upserted ${connection.kind.id}/${connection.id}'); } - /// Remove a stored connection. If the removed row was the default, the - /// oldest remaining connection (if any) becomes default. + /// Remove a stored connection. Future remove(String id) async { await _db.runIdentityMutation(() async { await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go(); - final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get(); - if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) { - await (_db.update( - _db.connections, - )..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true))); - } }); appLogger.d('ConnectionRegistry: removed $id'); } - /// Set [id] as the default connection. Clears the flag on all others. - Future setDefault(String id) async { - await _db.runIdentityMutation(() async { - await _db.transaction(() async { - await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false))); - await (_db.update( - _db.connections, - )..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true))); - }); - }); - } - - /// Update only the auth-related metadata on an existing row (token, - /// `lastAuthenticatedAt`). Used by the auth flow after a successful - /// silent refresh without touching the rest of the config. - Future recordAuthSuccess(String id, DateTime at) async { - await _db.runIdentityMutation(() async { - await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write( - ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)), - ); - }); - } - Future clear() async { await _db.runIdentityMutation(() async { await _db.delete(_db.connections).go(); diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 412c74dbe..eef02aaf8 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -37,15 +37,6 @@ enum OfflineActionType { OfflineActionType.watched => 'watched', OfflineActionType.unwatched => 'unwatched', }; - - /// Inverse of [id]. Throws on unknown so a typo in production doesn't - /// silently fall back to the wrong action. - static OfflineActionType fromId(String id) => switch (id) { - 'progress' => OfflineActionType.progress, - 'watched' => OfflineActionType.watched, - 'unwatched' => OfflineActionType.unwatched, - _ => throw ArgumentError('Unknown OfflineActionType id: $id'), - }; } final class AppDatabaseBootstrap { @@ -319,6 +310,14 @@ class AppDatabase extends _$AppDatabase { }); } + /// Columns that once existed in a snapshotted table and may still appear in + /// committed recovery images written by older builds. They are stripped + /// before the strict round-trip check in [_decodeRecoveryRow] so retiring a + /// column does not brick restore on devices holding a pre-retirement image. + static const Map> _retiredRecoveryColumns = { + 'connections': {'isDefault'}, + }; + static List _decodeRecoveryRows( Map group, String key, @@ -326,9 +325,21 @@ class AppDatabase extends _$AppDatabase { ) { final value = group[key]; if (value is! List) throw _invalidRecoveryImage; + final retired = _retiredRecoveryColumns[key]; return [ for (final row in value) - if (row is Map) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage, + if (row is Map) + _decodeRecoveryRow( + retired == null + ? row + : { + for (final entry in row.entries) + if (!retired.contains(entry.key)) entry.key: entry.value, + }, + fromJson, + ) + else + throw _invalidRecoveryImage, ]; } @@ -354,7 +365,7 @@ class AppDatabase extends _$AppDatabase { static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image'); @override - int get schemaVersion => 20; + int get schemaVersion => 21; @override MigrationStrategy get migration { @@ -720,6 +731,11 @@ class AppDatabase extends _$AppDatabase { () => m.create(idxSyncRuleDownloadsProfileKey), ); } + if (from < 21) { + appLogger.i('Dropping unused Connections.isDefault and ApiCache.cachedAt columns (v21 migration)'); + await m.alterTable(TableMigration(connections)); + await m.alterTable(TableMigration(apiCache)); + } }, ); } @@ -1251,9 +1267,6 @@ class AppDatabase extends _$AppDatabase { Future updateSyncRuleEnabled(String globalKey, bool enabled) => _writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled))); - Future updateSyncRuleLastExecuted(String globalKey) => - _writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch))); - Future completeSyncRuleExecution(String globalKey) { return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write( SyncRulesCompanion( @@ -1311,14 +1324,13 @@ String _rescopePinnedPlexMetadataStatement({ WHERE grandparent_rating_key IS NOT NULL AND grandparent_rating_key != '' ) - INSERT INTO api_cache (cache_key, data, pinned, cached_at) + INSERT INTO api_cache (cache_key, data, pinned) SELECT DISTINCT metadata.server_id || $namespaceExpression || substr(source.cache_key, length(metadata.server_id) + 2), source.data, - source.pinned, - source.cached_at + source.pinned FROM download_metadata_ids AS metadata $ownerJoin JOIN api_cache AS source @@ -1330,8 +1342,7 @@ String _rescopePinnedPlexMetadataStatement({ $ownerFilter ON CONFLICT(cache_key) DO UPDATE SET data = excluded.data, - pinned = excluded.pinned, - cached_at = excluded.cached_at + pinned = excluded.pinned '''; Future _resolveProductionDatabaseFile() async { diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index a818e3c11..5c33ea5e6 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -2036,20 +2036,8 @@ class $ApiCacheTable extends ApiCache ), defaultValue: const Constant(false), ); - static const VerificationMeta _cachedAtMeta = const VerificationMeta( - 'cachedAt', - ); @override - late final GeneratedColumn cachedAt = GeneratedColumn( - 'cached_at', - aliasedName, - false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime, - ); - @override - List get $columns => [cacheKey, data, pinned, cachedAt]; + List get $columns => [cacheKey, data, pinned]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -2084,12 +2072,6 @@ class $ApiCacheTable extends ApiCache pinned.isAcceptableOrUnknown(data['pinned']!, _pinnedMeta), ); } - if (data.containsKey('cached_at')) { - context.handle( - _cachedAtMeta, - cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), - ); - } return context; } @@ -2111,10 +2093,6 @@ class $ApiCacheTable extends ApiCache DriftSqlType.bool, data['${effectivePrefix}pinned'], )!, - cachedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}cached_at'], - )!, ); } @@ -2132,14 +2110,10 @@ class ApiCacheData extends DataClass implements Insertable { /// Whether this item is pinned for offline access final bool pinned; - - /// Timestamp for cache invalidation (optional future use) - final DateTime cachedAt; const ApiCacheData({ required this.cacheKey, required this.data, required this.pinned, - required this.cachedAt, }); @override Map toColumns(bool nullToAbsent) { @@ -2147,7 +2121,6 @@ class ApiCacheData extends DataClass implements Insertable { map['cache_key'] = Variable(cacheKey); map['data'] = Variable(data); map['pinned'] = Variable(pinned); - map['cached_at'] = Variable(cachedAt); return map; } @@ -2156,7 +2129,6 @@ class ApiCacheData extends DataClass implements Insertable { cacheKey: Value(cacheKey), data: Value(data), pinned: Value(pinned), - cachedAt: Value(cachedAt), ); } @@ -2169,7 +2141,6 @@ class ApiCacheData extends DataClass implements Insertable { cacheKey: serializer.fromJson(json['cacheKey']), data: serializer.fromJson(json['data']), pinned: serializer.fromJson(json['pinned']), - cachedAt: serializer.fromJson(json['cachedAt']), ); } @override @@ -2179,27 +2150,20 @@ class ApiCacheData extends DataClass implements Insertable { 'cacheKey': serializer.toJson(cacheKey), 'data': serializer.toJson(data), 'pinned': serializer.toJson(pinned), - 'cachedAt': serializer.toJson(cachedAt), }; } - ApiCacheData copyWith({ - String? cacheKey, - String? data, - bool? pinned, - DateTime? cachedAt, - }) => ApiCacheData( - cacheKey: cacheKey ?? this.cacheKey, - data: data ?? this.data, - pinned: pinned ?? this.pinned, - cachedAt: cachedAt ?? this.cachedAt, - ); + ApiCacheData copyWith({String? cacheKey, String? data, bool? pinned}) => + ApiCacheData( + cacheKey: cacheKey ?? this.cacheKey, + data: data ?? this.data, + pinned: pinned ?? this.pinned, + ); ApiCacheData copyWithCompanion(ApiCacheCompanion data) { return ApiCacheData( cacheKey: data.cacheKey.present ? data.cacheKey.value : this.cacheKey, data: data.data.present ? data.data.value : this.data, pinned: data.pinned.present ? data.pinned.value : this.pinned, - cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt, ); } @@ -2208,42 +2172,37 @@ class ApiCacheData extends DataClass implements Insertable { return (StringBuffer('ApiCacheData(') ..write('cacheKey: $cacheKey, ') ..write('data: $data, ') - ..write('pinned: $pinned, ') - ..write('cachedAt: $cachedAt') + ..write('pinned: $pinned') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(cacheKey, data, pinned, cachedAt); + int get hashCode => Object.hash(cacheKey, data, pinned); @override bool operator ==(Object other) => identical(this, other) || (other is ApiCacheData && other.cacheKey == this.cacheKey && other.data == this.data && - other.pinned == this.pinned && - other.cachedAt == this.cachedAt); + other.pinned == this.pinned); } class ApiCacheCompanion extends UpdateCompanion { final Value cacheKey; final Value data; final Value pinned; - final Value cachedAt; final Value rowid; const ApiCacheCompanion({ this.cacheKey = const Value.absent(), this.data = const Value.absent(), this.pinned = const Value.absent(), - this.cachedAt = const Value.absent(), this.rowid = const Value.absent(), }); ApiCacheCompanion.insert({ required String cacheKey, required String data, this.pinned = const Value.absent(), - this.cachedAt = const Value.absent(), this.rowid = const Value.absent(), }) : cacheKey = Value(cacheKey), data = Value(data); @@ -2251,14 +2210,12 @@ class ApiCacheCompanion extends UpdateCompanion { Expression? cacheKey, Expression? data, Expression? pinned, - Expression? cachedAt, Expression? rowid, }) { return RawValuesInsertable({ if (cacheKey != null) 'cache_key': cacheKey, if (data != null) 'data': data, if (pinned != null) 'pinned': pinned, - if (cachedAt != null) 'cached_at': cachedAt, if (rowid != null) 'rowid': rowid, }); } @@ -2267,14 +2224,12 @@ class ApiCacheCompanion extends UpdateCompanion { Value? cacheKey, Value? data, Value? pinned, - Value? cachedAt, Value? rowid, }) { return ApiCacheCompanion( cacheKey: cacheKey ?? this.cacheKey, data: data ?? this.data, pinned: pinned ?? this.pinned, - cachedAt: cachedAt ?? this.cachedAt, rowid: rowid ?? this.rowid, ); } @@ -2291,9 +2246,6 @@ class ApiCacheCompanion extends UpdateCompanion { if (pinned.present) { map['pinned'] = Variable(pinned.value); } - if (cachedAt.present) { - map['cached_at'] = Variable(cachedAt.value); - } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -2306,7 +2258,6 @@ class ApiCacheCompanion extends UpdateCompanion { ..write('cacheKey: $cacheKey, ') ..write('data: $data, ') ..write('pinned: $pinned, ') - ..write('cachedAt: $cachedAt, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -4333,21 +4284,6 @@ class $ConnectionsTable extends Connections type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _isDefaultMeta = const VerificationMeta( - 'isDefault', - ); - @override - late final GeneratedColumn isDefault = GeneratedColumn( - 'is_default', - aliasedName, - false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("is_default" IN (0, 1))', - ), - defaultValue: const Constant(false), - ); static const VerificationMeta _createdAtMeta = const VerificationMeta( 'createdAt', ); @@ -4375,7 +4311,6 @@ class $ConnectionsTable extends Connections kind, displayName, configJson, - isDefault, createdAt, lastAuthenticatedAt, ]; @@ -4423,12 +4358,6 @@ class $ConnectionsTable extends Connections } else if (isInserting) { context.missing(_configJsonMeta); } - if (data.containsKey('is_default')) { - context.handle( - _isDefaultMeta, - isDefault.isAcceptableOrUnknown(data['is_default']!, _isDefaultMeta), - ); - } if (data.containsKey('created_at')) { context.handle( _createdAtMeta, @@ -4471,10 +4400,6 @@ class $ConnectionsTable extends Connections DriftSqlType.string, data['${effectivePrefix}config_json'], )!, - isDefault: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}is_default'], - )!, createdAt: attachedDatabase.typeMapping.read( DriftSqlType.int, data['${effectivePrefix}created_at'], @@ -4506,10 +4431,6 @@ class ConnectionRow extends DataClass implements Insertable { /// Backend-specific config payload (token, baseUrl, profile id, …). final String configJson; - /// Whether this is the default connection used at app launch when only - /// one connection is present. - final bool isDefault; - /// Timestamp this connection was added (milliseconds since epoch). final int createdAt; @@ -4521,7 +4442,6 @@ class ConnectionRow extends DataClass implements Insertable { required this.kind, required this.displayName, required this.configJson, - required this.isDefault, required this.createdAt, this.lastAuthenticatedAt, }); @@ -4532,7 +4452,6 @@ class ConnectionRow extends DataClass implements Insertable { map['kind'] = Variable(kind); map['display_name'] = Variable(displayName); map['config_json'] = Variable(configJson); - map['is_default'] = Variable(isDefault); map['created_at'] = Variable(createdAt); if (!nullToAbsent || lastAuthenticatedAt != null) { map['last_authenticated_at'] = Variable(lastAuthenticatedAt); @@ -4546,7 +4465,6 @@ class ConnectionRow extends DataClass implements Insertable { kind: Value(kind), displayName: Value(displayName), configJson: Value(configJson), - isDefault: Value(isDefault), createdAt: Value(createdAt), lastAuthenticatedAt: lastAuthenticatedAt == null && nullToAbsent ? const Value.absent() @@ -4564,7 +4482,6 @@ class ConnectionRow extends DataClass implements Insertable { kind: serializer.fromJson(json['kind']), displayName: serializer.fromJson(json['displayName']), configJson: serializer.fromJson(json['configJson']), - isDefault: serializer.fromJson(json['isDefault']), createdAt: serializer.fromJson(json['createdAt']), lastAuthenticatedAt: serializer.fromJson( json['lastAuthenticatedAt'], @@ -4579,7 +4496,6 @@ class ConnectionRow extends DataClass implements Insertable { 'kind': serializer.toJson(kind), 'displayName': serializer.toJson(displayName), 'configJson': serializer.toJson(configJson), - 'isDefault': serializer.toJson(isDefault), 'createdAt': serializer.toJson(createdAt), 'lastAuthenticatedAt': serializer.toJson(lastAuthenticatedAt), }; @@ -4590,7 +4506,6 @@ class ConnectionRow extends DataClass implements Insertable { String? kind, String? displayName, String? configJson, - bool? isDefault, int? createdAt, Value lastAuthenticatedAt = const Value.absent(), }) => ConnectionRow( @@ -4598,7 +4513,6 @@ class ConnectionRow extends DataClass implements Insertable { kind: kind ?? this.kind, displayName: displayName ?? this.displayName, configJson: configJson ?? this.configJson, - isDefault: isDefault ?? this.isDefault, createdAt: createdAt ?? this.createdAt, lastAuthenticatedAt: lastAuthenticatedAt.present ? lastAuthenticatedAt.value @@ -4614,7 +4528,6 @@ class ConnectionRow extends DataClass implements Insertable { configJson: data.configJson.present ? data.configJson.value : this.configJson, - isDefault: data.isDefault.present ? data.isDefault.value : this.isDefault, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, lastAuthenticatedAt: data.lastAuthenticatedAt.present ? data.lastAuthenticatedAt.value @@ -4629,7 +4542,6 @@ class ConnectionRow extends DataClass implements Insertable { ..write('kind: $kind, ') ..write('displayName: $displayName, ') ..write('configJson: $configJson, ') - ..write('isDefault: $isDefault, ') ..write('createdAt: $createdAt, ') ..write('lastAuthenticatedAt: $lastAuthenticatedAt') ..write(')')) @@ -4642,7 +4554,6 @@ class ConnectionRow extends DataClass implements Insertable { kind, displayName, configJson, - isDefault, createdAt, lastAuthenticatedAt, ); @@ -4654,7 +4565,6 @@ class ConnectionRow extends DataClass implements Insertable { other.kind == this.kind && other.displayName == this.displayName && other.configJson == this.configJson && - other.isDefault == this.isDefault && other.createdAt == this.createdAt && other.lastAuthenticatedAt == this.lastAuthenticatedAt); } @@ -4664,7 +4574,6 @@ class ConnectionsCompanion extends UpdateCompanion { final Value kind; final Value displayName; final Value configJson; - final Value isDefault; final Value createdAt; final Value lastAuthenticatedAt; final Value rowid; @@ -4673,7 +4582,6 @@ class ConnectionsCompanion extends UpdateCompanion { this.kind = const Value.absent(), this.displayName = const Value.absent(), this.configJson = const Value.absent(), - this.isDefault = const Value.absent(), this.createdAt = const Value.absent(), this.lastAuthenticatedAt = const Value.absent(), this.rowid = const Value.absent(), @@ -4683,7 +4591,6 @@ class ConnectionsCompanion extends UpdateCompanion { required String kind, required String displayName, required String configJson, - this.isDefault = const Value.absent(), required int createdAt, this.lastAuthenticatedAt = const Value.absent(), this.rowid = const Value.absent(), @@ -4697,7 +4604,6 @@ class ConnectionsCompanion extends UpdateCompanion { Expression? kind, Expression? displayName, Expression? configJson, - Expression? isDefault, Expression? createdAt, Expression? lastAuthenticatedAt, Expression? rowid, @@ -4707,7 +4613,6 @@ class ConnectionsCompanion extends UpdateCompanion { if (kind != null) 'kind': kind, if (displayName != null) 'display_name': displayName, if (configJson != null) 'config_json': configJson, - if (isDefault != null) 'is_default': isDefault, if (createdAt != null) 'created_at': createdAt, if (lastAuthenticatedAt != null) 'last_authenticated_at': lastAuthenticatedAt, @@ -4720,7 +4625,6 @@ class ConnectionsCompanion extends UpdateCompanion { Value? kind, Value? displayName, Value? configJson, - Value? isDefault, Value? createdAt, Value? lastAuthenticatedAt, Value? rowid, @@ -4730,7 +4634,6 @@ class ConnectionsCompanion extends UpdateCompanion { kind: kind ?? this.kind, displayName: displayName ?? this.displayName, configJson: configJson ?? this.configJson, - isDefault: isDefault ?? this.isDefault, createdAt: createdAt ?? this.createdAt, lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt, rowid: rowid ?? this.rowid, @@ -4752,9 +4655,6 @@ class ConnectionsCompanion extends UpdateCompanion { if (configJson.present) { map['config_json'] = Variable(configJson.value); } - if (isDefault.present) { - map['is_default'] = Variable(isDefault.value); - } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); } @@ -4774,7 +4674,6 @@ class ConnectionsCompanion extends UpdateCompanion { ..write('kind: $kind, ') ..write('displayName: $displayName, ') ..write('configJson: $configJson, ') - ..write('isDefault: $isDefault, ') ..write('createdAt: $createdAt, ') ..write('lastAuthenticatedAt: $lastAuthenticatedAt, ') ..write('rowid: $rowid') @@ -6910,7 +6809,6 @@ typedef $$ApiCacheTableCreateCompanionBuilder = required String cacheKey, required String data, Value pinned, - Value cachedAt, Value rowid, }); typedef $$ApiCacheTableUpdateCompanionBuilder = @@ -6918,7 +6816,6 @@ typedef $$ApiCacheTableUpdateCompanionBuilder = Value cacheKey, Value data, Value pinned, - Value cachedAt, Value rowid, }); @@ -6945,11 +6842,6 @@ class $$ApiCacheTableFilterComposer column: $table.pinned, builder: (column) => ColumnFilters(column), ); - - ColumnFilters get cachedAt => $composableBuilder( - column: $table.cachedAt, - builder: (column) => ColumnFilters(column), - ); } class $$ApiCacheTableOrderingComposer @@ -6975,11 +6867,6 @@ class $$ApiCacheTableOrderingComposer column: $table.pinned, builder: (column) => ColumnOrderings(column), ); - - ColumnOrderings get cachedAt => $composableBuilder( - column: $table.cachedAt, - builder: (column) => ColumnOrderings(column), - ); } class $$ApiCacheTableAnnotationComposer @@ -6999,9 +6886,6 @@ class $$ApiCacheTableAnnotationComposer GeneratedColumn get pinned => $composableBuilder(column: $table.pinned, builder: (column) => column); - - GeneratedColumn get cachedAt => - $composableBuilder(column: $table.cachedAt, builder: (column) => column); } class $$ApiCacheTableTableManager @@ -7038,13 +6922,11 @@ class $$ApiCacheTableTableManager Value cacheKey = const Value.absent(), Value data = const Value.absent(), Value pinned = const Value.absent(), - Value cachedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ApiCacheCompanion( cacheKey: cacheKey, data: data, pinned: pinned, - cachedAt: cachedAt, rowid: rowid, ), createCompanionCallback: @@ -7052,13 +6934,11 @@ class $$ApiCacheTableTableManager required String cacheKey, required String data, Value pinned = const Value.absent(), - Value cachedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ApiCacheCompanion.insert( cacheKey: cacheKey, data: data, pinned: pinned, - cachedAt: cachedAt, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -8269,7 +8149,6 @@ typedef $$ConnectionsTableCreateCompanionBuilder = required String kind, required String displayName, required String configJson, - Value isDefault, required int createdAt, Value lastAuthenticatedAt, Value rowid, @@ -8280,7 +8159,6 @@ typedef $$ConnectionsTableUpdateCompanionBuilder = Value kind, Value displayName, Value configJson, - Value isDefault, Value createdAt, Value lastAuthenticatedAt, Value rowid, @@ -8344,11 +8222,6 @@ class $$ConnectionsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get isDefault => $composableBuilder( - column: $table.isDefault, - builder: (column) => ColumnFilters(column), - ); - ColumnFilters get createdAt => $composableBuilder( column: $table.createdAt, builder: (column) => ColumnFilters(column), @@ -8414,11 +8287,6 @@ class $$ConnectionsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get isDefault => $composableBuilder( - column: $table.isDefault, - builder: (column) => ColumnOrderings(column), - ); - ColumnOrderings get createdAt => $composableBuilder( column: $table.createdAt, builder: (column) => ColumnOrderings(column), @@ -8455,9 +8323,6 @@ class $$ConnectionsTableAnnotationComposer builder: (column) => column, ); - GeneratedColumn get isDefault => - $composableBuilder(column: $table.isDefault, builder: (column) => column); - GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); @@ -8525,7 +8390,6 @@ class $$ConnectionsTableTableManager Value kind = const Value.absent(), Value displayName = const Value.absent(), Value configJson = const Value.absent(), - Value isDefault = const Value.absent(), Value createdAt = const Value.absent(), Value lastAuthenticatedAt = const Value.absent(), Value rowid = const Value.absent(), @@ -8534,7 +8398,6 @@ class $$ConnectionsTableTableManager kind: kind, displayName: displayName, configJson: configJson, - isDefault: isDefault, createdAt: createdAt, lastAuthenticatedAt: lastAuthenticatedAt, rowid: rowid, @@ -8545,7 +8408,6 @@ class $$ConnectionsTableTableManager required String kind, required String displayName, required String configJson, - Value isDefault = const Value.absent(), required int createdAt, Value lastAuthenticatedAt = const Value.absent(), Value rowid = const Value.absent(), @@ -8554,7 +8416,6 @@ class $$ConnectionsTableTableManager kind: kind, displayName: displayName, configJson: configJson, - isDefault: isDefault, createdAt: createdAt, lastAuthenticatedAt: lastAuthenticatedAt, rowid: rowid, diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index f154d1c5c..0fb16d88c 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -145,10 +145,6 @@ extension DownloadDatabaseOperations on AppDatabase { ); } - Future getDownloadOwnerCount(String globalKey) async { - return (await _validDownloadOwnerRows(globalKey)).length; - } - @visibleForTesting Future hasDownloadOwner(String globalKey, {String? excludingProfileId}) async { final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId); @@ -190,8 +186,8 @@ extension DownloadDatabaseOperations on AppDatabase { /// inherit them. /// /// Runs on every profile switch — validity context is computed once and - /// applied in memory instead of the per-download full-table rescan - /// `getDownloadOwnerCount` would do. + /// applied in memory instead of a per-download full-table rescan through + /// `_validDownloadOwnerRows`. Future adoptLegacyDownloadsForProfile(String profileId, {bool Function()? isStillActive}) async { if (profileId.isEmpty) return; if (isStillActive != null && !isStillActive()) return; diff --git a/lib/database/tables.dart b/lib/database/tables.dart index 38fcdb1f6..c9710afec 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -12,9 +12,6 @@ class ApiCache extends Table { /// Whether this item is pinned for offline access BoolColumn get pinned => boolean().withDefault(const Constant(false))(); - /// Timestamp for cache invalidation (optional future use) - DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)(); - @override Set get primaryKey => {cacheKey}; } @@ -150,10 +147,6 @@ class Connections extends Table { /// Backend-specific config payload (token, baseUrl, profile id, …). TextColumn get configJson => text()(); - /// Whether this is the default connection used at app launch when only - /// one connection is present. - BoolColumn get isDefault => boolean().withDefault(const Constant(false))(); - /// Timestamp this connection was added (milliseconds since epoch). IntColumn get createdAt => integer()(); diff --git a/lib/main.dart b/lib/main.dart index b222823d6..4c24acb98 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2077,7 +2077,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { // Wire the per-server status listener before either branch so the splash // checkmarks fill in even while the user is choosing a profile. - _bindServerStatusListener(activeProfile, _serverManagerFromContext); + _bindServerStatusListener(); // Start only after network/offline startup has been decided and the // active profile snapshot is hydrated. This prevents an eager binder @@ -2144,10 +2144,10 @@ class _SetupScreenState extends State with MountedSetStateMixin { StreamSubscription>? _statusSub; StreamSubscription<({String serverId, bool online})>? _connectProgressSub; - void _bindServerStatusListener(ActiveProfileProvider _, MultiServerManager Function() resolveManager) { + void _bindServerStatusListener() { _statusSub?.cancel(); _connectProgressSub?.cancel(); - final manager = resolveManager(); + final manager = _serverManagerFromContext(); _connectProgressSub = manager.connectProgressStream.listen((progress) { if (!mounted) return; final existing = _serverStatus[progress.serverId]; diff --git a/test/connection/connection_registry_test.dart b/test/connection/connection_registry_test.dart index f2e7e9ded..81edb9026 100644 --- a/test/connection/connection_registry_test.dart +++ b/test/connection/connection_registry_test.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/connection/connection.dart'; @@ -13,23 +12,14 @@ import 'package:plezy/services/plex_auth_service.dart'; import '../test_helpers/prefs.dart'; -/// The id of the connection currently flagged default, read straight from the -/// row (the registry maintains the flag; there is no public reader). -Future _defaultConnectionId(AppDatabase db) async { - for (final row in await db.select(db.connections).get()) { - if (row.isDefault) return row.id; - } - return null; -} - -JellyfinConnection _jellyfin({String id = 'srv-1', String userName = 'edde', int createdAtMs = 1_000_000}) { +JellyfinConnection _jellyfin({String id = 'srv-1', int createdAtMs = 1_000_000}) { return JellyfinConnection( id: id, baseUrl: 'https://jellyfin.local', serverName: 'Home', serverMachineId: 'jf-machine-$id', userId: 'user-$id', - userName: userName, + userName: 'edde', accessToken: 'tok-$id', deviceId: 'dev-1', createdAt: DateTime.fromMillisecondsSinceEpoch(createdAtMs), @@ -97,16 +87,13 @@ void main() { group('ConnectionRegistry', () { test('list() returns empty when no connections stored', () async { expect(await registry.list(), isEmpty); - expect(await _defaultConnectionId(db), isNull); }); - test('first upserted connection becomes the default', () async { + test('upserted connection round-trips through list()', () async { await registry.upsert(_jellyfin(id: 'a')); final list = await registry.list(); expect(list.length, 1); expect(list.first.id, 'a'); - - expect(await _defaultConnectionId(db), 'a'); }); test('upsert preserves type discriminator (Plex vs Jellyfin)', () async { @@ -180,7 +167,6 @@ void main() { displayName: plex.displayName, configJson: jsonEncode(config), createdAt: plex.createdAt.millisecondsSinceEpoch, - isDefault: const Value(true), ), ); @@ -192,37 +178,13 @@ void main() { expect(row.configJson, isNot(contains('server-token-legacy'))); }); - test('setDefault flips the flag and clears it on others', () async { - await registry.upsert(_jellyfin(id: 'a')); - await registry.upsert(_jellyfin(id: 'b')); - await registry.setDefault('b'); - expect(await _defaultConnectionId(db), 'b'); - await registry.setDefault('a'); - expect(await _defaultConnectionId(db), 'a'); - }); - - test('remove deletes a row and re-elects a default when needed', () async { + test('remove deletes a row', () async { await registry.upsert(_jellyfin(id: 'a')); await registry.upsert(_jellyfin(id: 'b')); await registry.remove('a'); - expect(await _defaultConnectionId(db), 'b'); + expect((await registry.list()).map((c) => c.id).toList(), ['b']); await registry.remove('b'); - expect(await _defaultConnectionId(db), isNull); - }); - - test('re-upsert preserves the existing default flag', () async { - // Regression: a token/metadata refresh that re-upserts an existing - // default row used to clear `isDefault` because the writer always - // wrote `isFirst` (false on update). - await registry.upsert(_jellyfin(id: 'a')); - await registry.upsert(_jellyfin(id: 'b')); - expect(await _defaultConnectionId(db), 'a'); - - await registry.upsert(_jellyfin(id: 'a', userName: 'refreshed')); - expect(await _defaultConnectionId(db), 'a'); - - await registry.upsert(_jellyfin(id: 'b', userName: 'refreshed')); - expect(await _defaultConnectionId(db), 'a'); + expect(await registry.list(), isEmpty); }); test('re-upsert preserves the original creation order', () async { @@ -246,16 +208,5 @@ void main() { expect((await registry.list()).single.createdAt, DateTime.fromMillisecondsSinceEpoch(5_000_000)); }); - - test('recordAuthSuccess updates lastAuthenticatedAt without losing config', () async { - await registry.upsert(_jellyfin(id: 'a')); - final at = DateTime.fromMillisecondsSinceEpoch(2_000_000); - await registry.recordAuthSuccess('a', at); - - final c = await registry.get('a') as JellyfinConnection; - expect(c.lastAuthenticatedAt, at); - expect(c.baseUrl, 'https://jellyfin.local'); - expect(c.accessToken, 'tok-a'); - }); }); } diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index 685455ea3..223ac45c7 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -278,7 +278,7 @@ class _AppDatabaseTestSuite { expect(downloads.map((row) => row.globalKey), ['plex-server:item']); expect(await database.getDownloadOwnerKeysForProfile('profile-a'), {'plex-server:item'}); expect(await database.getDownloadOwnerKeysForProfile('profile-b'), {'plex-server:item'}); - expect(await database.getDownloadOwnerCount('plex-server:item'), 2); + expect(await database.getValidDownloadOwnersForKey('plex-server:item'), hasLength(2)); } try { @@ -926,6 +926,66 @@ class _AppDatabaseTestSuite { db = AppDatabase.forTesting(NativeDatabase.memory()); } }); + test('v21 migration drops connections.is_default and api_cache.cached_at without losing rows', () async { + await db.close(); + final tempDir = await Directory.systemTemp.createTemp('plezy_db_v21_migration_test_'); + final file = File('${tempDir.path}/plezy_downloads.db'); + AppDatabase? seeded; + AppDatabase? reopened; + + try { + seeded = AppDatabase.forTesting(NativeDatabase(file)); + await seeded.select(seeded.connections).get(); + await seeded + .into(seeded.connections) + .insert( + ConnectionsCompanion.insert( + id: 'c1', + kind: 'plex', + displayName: 'C1', + configJson: '{}', + createdAt: 1000, + ), + ); + await seeded.customStatement('INSERT INTO api_cache (cache_key, data, pinned) VALUES (?, ?, 1)', [ + 'srv:/library/metadata/1', + '{}', + ]); + await seeded.customStatement('ALTER TABLE connections ADD COLUMN is_default INTEGER NOT NULL DEFAULT 1'); + await seeded.customStatement('ALTER TABLE api_cache ADD COLUMN cached_at INTEGER NOT NULL DEFAULT 0'); + await seeded.customStatement('PRAGMA user_version = 20'); + await seeded.close(); + seeded = null; + + reopened = AppDatabase.forTesting(NativeDatabase(file)); + final connectionColumns = (await reopened.customSelect("PRAGMA table_info('connections')").get()) + .map((row) => row.read('name')) + .toSet(); + final cacheColumns = (await reopened.customSelect("PRAGMA table_info('api_cache')").get()) + .map((row) => row.read('name')) + .toSet(); + expect(connectionColumns, isNot(contains('is_default'))); + expect(cacheColumns, isNot(contains('cached_at'))); + + final connection = await reopened.select(reopened.connections).getSingle(); + expect(connection.id, 'c1'); + expect(connection.createdAt, 1000); + final cacheRow = await reopened.select(reopened.apiCache).getSingle(); + expect(cacheRow.cacheKey, 'srv:/library/metadata/1'); + expect(cacheRow.pinned, isTrue); + + // The drift table recreation must restore the kind index. + final indexRows = await reopened + .customSelect("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_connections_kind'") + .get(); + expect(indexRows, hasLength(1)); + } finally { + await reopened?.close(); + await seeded?.close(); + await tempDir.delete(recursive: true); + db = AppDatabase.forTesting(NativeDatabase.memory()); + } + }); }); _registerLegacyDesktopMigrationTests(); @@ -1254,7 +1314,7 @@ class _AppDatabaseTestSuite { expect(await db.getDownloadOwnerKeysForProfile('profile-a'), {'srv1:1'}); expect(await db.getDownloadOwnerKeysForProfile('profile-b'), {'srv1:1'}); - expect(await db.getDownloadOwnerCount('srv1:1'), 2); + expect(await db.getValidDownloadOwnersForKey('srv1:1'), hasLength(2)); await db.removeDownloadOwner(profileId: 'profile-a', globalKey: 'srv1:1'); expect(await db.getDownloadOwnerKeysForProfile('profile-a'), isEmpty); @@ -2143,7 +2203,7 @@ class _AppDatabaseTestSuite { episodeCount: 5, ); await db.updateSyncRuleEnabled('srv:10', false); - await db.updateSyncRuleLastExecuted('srv:10'); + await db.completeSyncRuleExecution('srv:10'); final firstRun = (await db.getSyncRule('srv:10'))!; await db.insertSyncRule( @@ -2215,7 +2275,7 @@ class _AppDatabaseTestSuite { expect((await db.getSyncRule('srv:10'))!.enabled, isTrue); }); - test('updateSyncRuleLastExecuted writes a timestamp', () async { + test('completeSyncRuleExecution writes a timestamp and marks links initialized', () async { await db.insertSyncRule( serverId: ServerId('srv'), ratingKey: '10', @@ -2224,13 +2284,14 @@ class _AppDatabaseTestSuite { episodeCount: 5, ); final before = DateTime.now().millisecondsSinceEpoch; - await db.updateSyncRuleLastExecuted('srv:10'); + await db.completeSyncRuleExecution('srv:10'); final after = DateTime.now().millisecondsSinceEpoch; final rule = await db.getSyncRule('srv:10'); expect(rule!.lastExecutedAt, isNotNull); expect(rule.lastExecutedAt! >= before, isTrue); expect(rule.lastExecutedAt! <= after, isTrue); + expect(rule.downloadLinksInitialized, isTrue); }); test('deleteSyncRule removes the matching row', () async { diff --git a/test/database/download_operations_test.dart b/test/database/download_operations_test.dart index 7f2fafe4b..571fdf51e 100644 --- a/test/database/download_operations_test.dart +++ b/test/database/download_operations_test.dart @@ -922,7 +922,7 @@ void main() { await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:100'); await db.addDownloadOwner(profileId: 'profile-deleted', globalKey: 'srv:100'); - expect(await db.getDownloadOwnerCount('srv:100'), 1); + expect(await db.getValidDownloadOwnersForKey('srv:100'), hasLength(1)); expect(await db.hasDownloadOwner('srv:100', excludingProfileId: 'profile-a'), isFalse); }); @@ -931,7 +931,7 @@ void main() { await insertPlexConnection('account-1'); await db.addDownloadOwner(profileId: plexHomeProfileId, globalKey: 'srv:100'); - expect(await db.getDownloadOwnerCount('srv:100'), 1); + expect(await db.getValidDownloadOwnersForKey('srv:100'), hasLength(1)); expect(await db.hasDownloadOwner('srv:100'), isTrue); }); @@ -939,7 +939,7 @@ void main() { const plexHomeProfileId = 'plex-home-missing-account-00000000-0000-0000-0000-000000000001'; await db.addDownloadOwner(profileId: plexHomeProfileId, globalKey: 'srv:100'); - expect(await db.getDownloadOwnerCount('srv:100'), 0); + expect(await db.getValidDownloadOwnersForKey('srv:100'), isEmpty); expect(await db.hasDownloadOwner('srv:100'), isFalse); }); }); diff --git a/test/database/tvos_database_recovery_test.dart b/test/database/tvos_database_recovery_test.dart index d3235e719..42a465403 100644 --- a/test/database/tvos_database_recovery_test.dart +++ b/test/database/tvos_database_recovery_test.dart @@ -122,6 +122,9 @@ Future>> _criticalRows(AppDatabase db) async => [ await (db.select(db.offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(), ]; +Future _hasProfile(AppDatabase db, String id) async => + (await ProfileRegistry(db).list()).any((profile) => profile.id == id); + Future _seedCriticalRows(AppDatabase db) async { await ConnectionRegistry(db).upsert(_connection('server-1')); await ProfileRegistry(db).upsert(_profile('local-1')); @@ -394,7 +397,7 @@ void main() { ); await ProfileRegistry(first.database).upsert(_profile('after-$failurePhase')); - expect(await ProfileRegistry(first.database).get('after-$failurePhase'), isNotNull); + expect(await _hasProfile(first.database, 'after-$failurePhase'), isTrue); }); } @@ -452,7 +455,7 @@ void main() { final restored = await open(); expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); - expect(await ProfileRegistry(restored.database).get('survives-large-settings'), isNotNull); + expect(await _hasProfile(restored.database, 'survives-large-settings'), isTrue); }); group('startup classification', () { @@ -511,7 +514,7 @@ void main() { ProfileRegistry(restarted.database).upsert(_profile('blocked-after-restart')), throwsA(isA()), ); - expect(await ProfileRegistry(restarted.database).get('blocked-after-restart'), isNull); + expect(await _hasProfile(restarted.database, 'blocked-after-restart'), isFalse); await restarted.database.acknowledgeTvosDatabaseRecoveryRequired(); expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull); @@ -520,7 +523,7 @@ void main() { final restored = await open(); expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); - expect(await ProfileRegistry(restored.database).get('acknowledged'), isNotNull); + expect(await _hasProfile(restored.database, 'acknowledged'), isTrue); }); test('successful restore clears recovery-required gate on a materialized candidate', () async { @@ -537,7 +540,7 @@ void main() { final missing = await open(); expect(missing.recoveryOutcome, TvosDatabaseRecoveryOutcome.recoveryRequired); expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isTrue); - expect(await ProfileRegistry(missing.database).get('restored-after-restart'), isNull); + expect(await _hasProfile(missing.database, 'restored-after-restart'), isFalse); await missing.database.close(); database = null; @@ -548,7 +551,7 @@ void main() { expect(restarted.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); expect(prefs.getBool(TvosDatabaseRecoveryStore.recoveryRequiredKey), isNull); - expect(await ProfileRegistry(restarted.database).get('restored-after-restart'), isNotNull); + expect(await _hasProfile(restarted.database, 'restored-after-restart'), isTrue); }); test('failed marker removal replays a successful restore idempotently on restart', () async { @@ -683,7 +686,7 @@ void main() { expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); await ProfileRegistry(result.database).upsert(_profile('database-remains-authoritative')); - expect(await ProfileRegistry(result.database).get('database-remains-authoritative'), isNotNull); + expect(await _hasProfile(result.database, 'database-remains-authoritative'), isTrue); }); test('existing database remains authoritative when recovery image exceeds its budget', () async { @@ -696,7 +699,7 @@ void main() { expect(result.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); await ProfileRegistry(result.database).upsert(_profile('database-survives-recovery-budget')); - expect(await ProfileRegistry(result.database).get('database-survives-recovery-budget'), isNotNull); + expect(await _hasProfile(result.database, 'database-survives-recovery-budget'), isTrue); }); test('startup invalidation failure blocks identity mutation until durable retry', () async { @@ -727,7 +730,7 @@ void main() { throwsA(isA()), ); expect(failedInvalidations, 2); - expect(await ProfileRegistry(existing.database).get('blocked-by-stale-image'), isNull); + expect(await _hasProfile(existing.database, 'blocked-by-stale-image'), isFalse); expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), staleManifest); await existing.database.close(); database = null; @@ -735,8 +738,8 @@ void main() { final probeFile = File('${tempDir.path}/stale-image-probe.db'); final staleRestore = await AppDatabase.open(isTvos: true, databaseFile: probeFile, preferences: prefs); expect(staleRestore.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); - expect(await ProfileRegistry(staleRestore.database).get('stale-image'), isNotNull); - expect(await ProfileRegistry(staleRestore.database).get('blocked-by-stale-image'), isNull); + expect(await _hasProfile(staleRestore.database, 'stale-image'), isTrue); + expect(await _hasProfile(staleRestore.database, 'blocked-by-stale-image'), isFalse); await staleRestore.database.close(); await _deleteDatabase(probeFile); @@ -744,7 +747,7 @@ void main() { final retried = await open(store: store); expect(retried.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); await ProfileRegistry(retried.database).upsert(_profile('committed-after-retry')); - expect(await ProfileRegistry(retried.database).get('committed-after-retry'), isNotNull); + expect(await _hasProfile(retried.database, 'committed-after-retry'), isTrue); expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), isNot(staleManifest)); await retried.database.close(); database = null; @@ -752,9 +755,9 @@ void main() { final recovered = await open(); expect(recovered.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); - expect(await ProfileRegistry(recovered.database).get('stale-image'), isNotNull); - expect(await ProfileRegistry(recovered.database).get('committed-after-retry'), isNotNull); - expect(await ProfileRegistry(recovered.database).get('blocked-by-stale-image'), isNull); + expect(await _hasProfile(recovered.database, 'stale-image'), isTrue); + expect(await _hasProfile(recovered.database, 'committed-after-retry'), isTrue); + expect(await _hasProfile(recovered.database, 'blocked-by-stale-image'), isFalse); }); test('existing database repairs an interrupted manifest authoritatively', () async { @@ -768,13 +771,13 @@ void main() { final repaired = await open(); expect(repaired.recoveryOutcome, TvosDatabaseRecoveryOutcome.adoptedExistingDatabase); - expect(await ProfileRegistry(repaired.database).get('authoritative'), isNotNull); + expect(await _hasProfile(repaired.database, 'authoritative'), isTrue); expect(prefs.getString(TvosDatabaseRecoveryStore.manifestKey), contains('committed')); await closeAndDelete(); final restored = await open(); expect(restored.recoveryOutcome, TvosDatabaseRecoveryOutcome.restored); - expect(await ProfileRegistry(restored.database).get('authoritative'), isNotNull); + expect(await _hasProfile(restored.database, 'authoritative'), isTrue); }); }); @@ -1054,8 +1057,6 @@ void main() { await connections.upsert(_connection('c1')); await connections.upsert(_connection('c2')); - await connections.setDefault('c2'); - await connections.recordAuthSuccess('c2', DateTime.fromMillisecondsSinceEpoch(9000)); await profiles.upsert(_profile('p1')); await profiles.upsert(_profile('p2')); await profiles.markUsed('p1', DateTime.fromMillisecondsSinceEpoch(9100));