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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user