refactor(database): drop the never-read connections.isDefault and api_cache.cached_at columns

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.
This commit is contained in:
edde746
2026-08-17 19:02:23 +02:00
parent 7658587584
commit 071ae8e1c2
10 changed files with 149 additions and 323 deletions
+9 -57
View File
@@ -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<void> 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<void> 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<void> 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<void> 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<void> clear() async {
await _db.runIdentityMutation(() async {
await _db.delete(_db.connections).go();
+30 -19
View File
@@ -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<String, Set<String>> _retiredRecoveryColumns = {
'connections': {'isDefault'},
};
static List<T> _decodeRecoveryRows<T extends DataClass>(
Map<String, Object?> 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<String, dynamic>) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage,
if (row is Map<String, dynamic>)
_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<void> updateSyncRuleEnabled(String globalKey, bool enabled) =>
_writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled)));
Future<void> updateSyncRuleLastExecuted(String globalKey) =>
_writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)));
Future<void> 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<File> _resolveProductionDatabaseFile() async {
+10 -149
View File
@@ -2036,20 +2036,8 @@ class $ApiCacheTable extends ApiCache
),
defaultValue: const Constant(false),
);
static const VerificationMeta _cachedAtMeta = const VerificationMeta(
'cachedAt',
);
@override
late final GeneratedColumn<DateTime> cachedAt = GeneratedColumn<DateTime>(
'cached_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime,
);
@override
List<GeneratedColumn> get $columns => [cacheKey, data, pinned, cachedAt];
List<GeneratedColumn> 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<ApiCacheData> {
/// 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<String, Expression> toColumns(bool nullToAbsent) {
@@ -2147,7 +2121,6 @@ class ApiCacheData extends DataClass implements Insertable<ApiCacheData> {
map['cache_key'] = Variable<String>(cacheKey);
map['data'] = Variable<String>(data);
map['pinned'] = Variable<bool>(pinned);
map['cached_at'] = Variable<DateTime>(cachedAt);
return map;
}
@@ -2156,7 +2129,6 @@ class ApiCacheData extends DataClass implements Insertable<ApiCacheData> {
cacheKey: Value(cacheKey),
data: Value(data),
pinned: Value(pinned),
cachedAt: Value(cachedAt),
);
}
@@ -2169,7 +2141,6 @@ class ApiCacheData extends DataClass implements Insertable<ApiCacheData> {
cacheKey: serializer.fromJson<String>(json['cacheKey']),
data: serializer.fromJson<String>(json['data']),
pinned: serializer.fromJson<bool>(json['pinned']),
cachedAt: serializer.fromJson<DateTime>(json['cachedAt']),
);
}
@override
@@ -2179,27 +2150,20 @@ class ApiCacheData extends DataClass implements Insertable<ApiCacheData> {
'cacheKey': serializer.toJson<String>(cacheKey),
'data': serializer.toJson<String>(data),
'pinned': serializer.toJson<bool>(pinned),
'cachedAt': serializer.toJson<DateTime>(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<ApiCacheData> {
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<ApiCacheData> {
final Value<String> cacheKey;
final Value<String> data;
final Value<bool> pinned;
final Value<DateTime> cachedAt;
final Value<int> 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<ApiCacheData> {
Expression<String>? cacheKey,
Expression<String>? data,
Expression<bool>? pinned,
Expression<DateTime>? cachedAt,
Expression<int>? 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<ApiCacheData> {
Value<String>? cacheKey,
Value<String>? data,
Value<bool>? pinned,
Value<DateTime>? cachedAt,
Value<int>? 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<ApiCacheData> {
if (pinned.present) {
map['pinned'] = Variable<bool>(pinned.value);
}
if (cachedAt.present) {
map['cached_at'] = Variable<DateTime>(cachedAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
@@ -2306,7 +2258,6 @@ class ApiCacheCompanion extends UpdateCompanion<ApiCacheData> {
..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<bool> isDefault = GeneratedColumn<bool>(
'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<ConnectionRow> {
/// 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<ConnectionRow> {
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<ConnectionRow> {
map['kind'] = Variable<String>(kind);
map['display_name'] = Variable<String>(displayName);
map['config_json'] = Variable<String>(configJson);
map['is_default'] = Variable<bool>(isDefault);
map['created_at'] = Variable<int>(createdAt);
if (!nullToAbsent || lastAuthenticatedAt != null) {
map['last_authenticated_at'] = Variable<int>(lastAuthenticatedAt);
@@ -4546,7 +4465,6 @@ class ConnectionRow extends DataClass implements Insertable<ConnectionRow> {
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<ConnectionRow> {
kind: serializer.fromJson<String>(json['kind']),
displayName: serializer.fromJson<String>(json['displayName']),
configJson: serializer.fromJson<String>(json['configJson']),
isDefault: serializer.fromJson<bool>(json['isDefault']),
createdAt: serializer.fromJson<int>(json['createdAt']),
lastAuthenticatedAt: serializer.fromJson<int?>(
json['lastAuthenticatedAt'],
@@ -4579,7 +4496,6 @@ class ConnectionRow extends DataClass implements Insertable<ConnectionRow> {
'kind': serializer.toJson<String>(kind),
'displayName': serializer.toJson<String>(displayName),
'configJson': serializer.toJson<String>(configJson),
'isDefault': serializer.toJson<bool>(isDefault),
'createdAt': serializer.toJson<int>(createdAt),
'lastAuthenticatedAt': serializer.toJson<int?>(lastAuthenticatedAt),
};
@@ -4590,7 +4506,6 @@ class ConnectionRow extends DataClass implements Insertable<ConnectionRow> {
String? kind,
String? displayName,
String? configJson,
bool? isDefault,
int? createdAt,
Value<int?> lastAuthenticatedAt = const Value.absent(),
}) => ConnectionRow(
@@ -4598,7 +4513,6 @@ class ConnectionRow extends DataClass implements Insertable<ConnectionRow> {
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<ConnectionRow> {
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<ConnectionRow> {
..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<ConnectionRow> {
kind,
displayName,
configJson,
isDefault,
createdAt,
lastAuthenticatedAt,
);
@@ -4654,7 +4565,6 @@ class ConnectionRow extends DataClass implements Insertable<ConnectionRow> {
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<ConnectionRow> {
final Value<String> kind;
final Value<String> displayName;
final Value<String> configJson;
final Value<bool> isDefault;
final Value<int> createdAt;
final Value<int?> lastAuthenticatedAt;
final Value<int> rowid;
@@ -4673,7 +4582,6 @@ class ConnectionsCompanion extends UpdateCompanion<ConnectionRow> {
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<ConnectionRow> {
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<ConnectionRow> {
Expression<String>? kind,
Expression<String>? displayName,
Expression<String>? configJson,
Expression<bool>? isDefault,
Expression<int>? createdAt,
Expression<int>? lastAuthenticatedAt,
Expression<int>? rowid,
@@ -4707,7 +4613,6 @@ class ConnectionsCompanion extends UpdateCompanion<ConnectionRow> {
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<ConnectionRow> {
Value<String>? kind,
Value<String>? displayName,
Value<String>? configJson,
Value<bool>? isDefault,
Value<int>? createdAt,
Value<int?>? lastAuthenticatedAt,
Value<int>? rowid,
@@ -4730,7 +4634,6 @@ class ConnectionsCompanion extends UpdateCompanion<ConnectionRow> {
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<ConnectionRow> {
if (configJson.present) {
map['config_json'] = Variable<String>(configJson.value);
}
if (isDefault.present) {
map['is_default'] = Variable<bool>(isDefault.value);
}
if (createdAt.present) {
map['created_at'] = Variable<int>(createdAt.value);
}
@@ -4774,7 +4674,6 @@ class ConnectionsCompanion extends UpdateCompanion<ConnectionRow> {
..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<bool> pinned,
Value<DateTime> cachedAt,
Value<int> rowid,
});
typedef $$ApiCacheTableUpdateCompanionBuilder =
@@ -6918,7 +6816,6 @@ typedef $$ApiCacheTableUpdateCompanionBuilder =
Value<String> cacheKey,
Value<String> data,
Value<bool> pinned,
Value<DateTime> cachedAt,
Value<int> rowid,
});
@@ -6945,11 +6842,6 @@ class $$ApiCacheTableFilterComposer
column: $table.pinned,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> 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<DateTime> get cachedAt => $composableBuilder(
column: $table.cachedAt,
builder: (column) => ColumnOrderings(column),
);
}
class $$ApiCacheTableAnnotationComposer
@@ -6999,9 +6886,6 @@ class $$ApiCacheTableAnnotationComposer
GeneratedColumn<bool> get pinned =>
$composableBuilder(column: $table.pinned, builder: (column) => column);
GeneratedColumn<DateTime> get cachedAt =>
$composableBuilder(column: $table.cachedAt, builder: (column) => column);
}
class $$ApiCacheTableTableManager
@@ -7038,13 +6922,11 @@ class $$ApiCacheTableTableManager
Value<String> cacheKey = const Value.absent(),
Value<String> data = const Value.absent(),
Value<bool> pinned = const Value.absent(),
Value<DateTime> cachedAt = const Value.absent(),
Value<int> 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<bool> pinned = const Value.absent(),
Value<DateTime> cachedAt = const Value.absent(),
Value<int> 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<bool> isDefault,
required int createdAt,
Value<int?> lastAuthenticatedAt,
Value<int> rowid,
@@ -8280,7 +8159,6 @@ typedef $$ConnectionsTableUpdateCompanionBuilder =
Value<String> kind,
Value<String> displayName,
Value<String> configJson,
Value<bool> isDefault,
Value<int> createdAt,
Value<int?> lastAuthenticatedAt,
Value<int> rowid,
@@ -8344,11 +8222,6 @@ class $$ConnectionsTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get isDefault => $composableBuilder(
column: $table.isDefault,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
@@ -8414,11 +8287,6 @@ class $$ConnectionsTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get isDefault => $composableBuilder(
column: $table.isDefault,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
@@ -8455,9 +8323,6 @@ class $$ConnectionsTableAnnotationComposer
builder: (column) => column,
);
GeneratedColumn<bool> get isDefault =>
$composableBuilder(column: $table.isDefault, builder: (column) => column);
GeneratedColumn<int> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
@@ -8525,7 +8390,6 @@ class $$ConnectionsTableTableManager
Value<String> kind = const Value.absent(),
Value<String> displayName = const Value.absent(),
Value<String> configJson = const Value.absent(),
Value<bool> isDefault = const Value.absent(),
Value<int> createdAt = const Value.absent(),
Value<int?> lastAuthenticatedAt = const Value.absent(),
Value<int> 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<bool> isDefault = const Value.absent(),
required int createdAt,
Value<int?> lastAuthenticatedAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
@@ -8554,7 +8416,6 @@ class $$ConnectionsTableTableManager
kind: kind,
displayName: displayName,
configJson: configJson,
isDefault: isDefault,
createdAt: createdAt,
lastAuthenticatedAt: lastAuthenticatedAt,
rowid: rowid,
+2 -6
View File
@@ -145,10 +145,6 @@ extension DownloadDatabaseOperations on AppDatabase {
);
}
Future<int> getDownloadOwnerCount(String globalKey) async {
return (await _validDownloadOwnerRows(globalKey)).length;
}
@visibleForTesting
Future<bool> 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<void> adoptLegacyDownloadsForProfile(String profileId, {bool Function()? isStillActive}) async {
if (profileId.isEmpty) return;
if (isStillActive != null && !isStillActive()) return;
-7
View File
@@ -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<Column> 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()();
+3 -3
View File
@@ -2077,7 +2077,7 @@ class _SetupScreenState extends State<SetupScreen> 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<SetupScreen> with MountedSetStateMixin {
StreamSubscription<Map<String, bool>>? _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];
+6 -55
View File
@@ -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<String?> _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');
});
});
}
+66 -5
View File
@@ -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<String>('name'))
.toSet();
final cacheColumns = (await reopened.customSelect("PRAGMA table_info('api_cache')").get())
.map((row) => row.read<String>('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 {
+3 -3
View File
@@ -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);
});
});
+20 -19
View File
@@ -122,6 +122,9 @@ Future<List<List<Object>>> _criticalRows(AppDatabase db) async => [
await (db.select(db.offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(),
];
Future<bool> _hasProfile(AppDatabase db, String id) async =>
(await ProfileRegistry(db).list()).any((profile) => profile.id == id);
Future<void> _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<TvosDatabaseDurabilityException>()),
);
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<TvosDatabaseDurabilityException>()),
);
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));