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
+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));