refactor(profiles): remove dead registry surface

Delete unused registry members (ProfileConnectionRegistry.insertIfAbsent /
removeAllForProfile, ProfileRegistry.reorder, ProfilesView.countFor,
ConnectionRegistry.getDefault) and their orphan tests, refreshing the stale
docs that named removeAllForProfile as the profile-delete cleanup path.

Repair the "one default per profile" join-row invariant: the connectionId FK
cascade (foreign_keys=ON) silently drops a profile's default row while its
other rows survive, leaving it defaultless. Add promoteMissingDefaults, share
a deterministic re-promotion helper with remove(), and re-promote in
removeAllForConnection.
This commit is contained in:
edde746
2026-07-02 11:41:25 +02:00
parent d193633a5d
commit 1059658515
8 changed files with 84 additions and 70 deletions
-12
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import '../database/app_database.dart';
@@ -41,17 +40,6 @@ class ConnectionRegistry {
return _rowToConnection(row);
}
/// Returns the user's preferred connection — either the row marked
/// [Connections.isDefault], or the only row if exactly one exists, or
/// null when no connections are stored.
Future<Connection?> getDefault() async {
final rows = await _db.select(_db.connections).get();
if (rows.isEmpty) return null;
final flagged = rows.firstWhereOrNull((r) => r.isDefault);
final picked = flagged ?? (rows.length == 1 ? rows.single : null);
return picked == null ? null : _rowToConnection(picked);
}
/// 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` (so token/metadata refreshes don't clear
+3 -3
View File
@@ -193,9 +193,9 @@ class ProfileConnections extends Table {
// No FK on profile_id: Plex Home profiles are virtual (built by
// Profile.virtualPlexHome from PlexHomeService's live cache, never
// persisted in `profiles`), so an FK here would reject every join row
// they need. The two profile-delete sites clean up join rows manually
// via ProfileConnectionRegistry.removeAllForProfile before calling
// ProfileRegistry.remove.
// they need. Profile deletion instead cleans up join rows explicitly
// (removeAllProfileConnectionsAndCleanup in profile_connection_cleanup)
// before calling ProfileRegistry.remove.
TextColumn get profileId => text()();
TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)();
TextColumn get userToken => text().withDefault(const Constant(''))();
+31 -22
View File
@@ -118,13 +118,6 @@ class ProfileConnectionRegistry {
);
}
/// Insert a new join row only if `(profileId, connectionId)` doesn't
/// already exist. Used by [ProfileSyncService] to surface new Plex Home
/// users without clobbering tokens cached by prior switches.
Future<void> insertIfAbsent(ProfileConnection pc) async {
await _db.into(_db.profileConnections).insert(await _companion(pc), mode: InsertMode.insertOrIgnore);
}
/// Cache the freshly-acquired user token (e.g. after a `/home/users/switch`
/// call). Updates `tokenAcquiredAt` to now.
Future<void> recordToken(String profileId, String connectionId, String token) async {
@@ -149,12 +142,30 @@ class ProfileConnectionRegistry {
await (_db.delete(
_db.profileConnections,
)..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(connectionId))).go();
// If we just removed the default, promote the oldest remaining row.
final remaining = await (_db.select(_db.profileConnections)..where((t) => t.profileId.equals(profileId))).get();
if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) {
await (_db.update(_db.profileConnections)
..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(remaining.first.connectionId)))
.write(const ProfileConnectionsCompanion(isDefault: Value(true)));
await _promoteDefaultIfMissing(profileId);
}
/// Re-promote a default for [profileId] when it has join rows but none is
/// flagged default — removing the default row would otherwise leave the
/// profile defaultless. Deterministic: picks the lowest connectionId,
/// matching [listForProfile]'s secondary ordering.
Future<void> _promoteDefaultIfMissing(String profileId) async {
final rows = await (_db.select(_db.profileConnections)..where((t) => t.profileId.equals(profileId))).get();
if (rows.isEmpty || rows.any((r) => r.isDefault)) return;
final pick = rows.map((r) => r.connectionId).reduce((a, b) => a.compareTo(b) <= 0 ? a : b);
await (_db.update(_db.profileConnections)
..where((t) => t.profileId.equals(profileId) & t.connectionId.equals(pick)))
.write(const ProfileConnectionsCompanion(isDefault: Value(true)));
}
/// Repair the "exactly one default per profile" invariant across every
/// profile. The `connectionId` FK cascade (PRAGMA foreign_keys=ON) deletes
/// join rows silently when a Connection is removed, so a profile can be left
/// with surviving rows but no default flag.
Future<void> promoteMissingDefaults() async {
final profileIds = (await _db.select(_db.profileConnections).get()).map((r) => r.profileId).toSet();
for (final profileId in profileIds) {
await _promoteDefaultIfMissing(profileId);
}
}
@@ -172,16 +183,14 @@ class ProfileConnectionRegistry {
}
/// Remove every join row referencing [connectionId] (e.g. when a Connection
/// is deleted). Drift's referential integrity isn't enabled by default for
/// SQLite without `PRAGMA foreign_keys=ON`, so we cascade explicitly.
/// is deleted). The `connectionId` FK (PRAGMA foreign_keys=ON) already
/// cascades these rows away when the Connection row itself is deleted; this
/// stays the explicit path for callers that drop the rows first, and either
/// way repairs any profile the removal left without a default.
Future<int> removeAllForConnection(String connectionId) async {
return await (_db.delete(_db.profileConnections)..where((t) => t.connectionId.equals(connectionId))).go();
}
/// Wipe every join row for [profileId] (e.g. when a Plex Home profile's
/// parent connection is removed).
Future<int> removeAllForProfile(String profileId) async {
return await (_db.delete(_db.profileConnections)..where((t) => t.profileId.equals(profileId))).go();
final removed = await (_db.delete(_db.profileConnections)..where((t) => t.connectionId.equals(connectionId))).go();
await promoteMissingDefaults();
return removed;
}
/// Wipe the entire join table. Used by sign-out so a fresh sign-in starts
-10
View File
@@ -73,16 +73,6 @@ class ProfileRegistry {
return (_db.delete(_db.profiles)..where((t) => t.kind.equals(ProfileKind.plexHome.id))).go();
}
Future<void> reorder(List<String> idsInOrder) async {
await _db.transaction(() async {
for (var i = 0; i < idsInOrder.length; i++) {
await (_db.update(
_db.profiles,
)..where((t) => t.id.equals(idsInOrder[i]))).write(ProfilesCompanion(sortOrder: Value(i)));
}
});
}
Future<void> clear() async {
await _db.delete(_db.profiles).go();
}
-5
View File
@@ -28,11 +28,6 @@ class ProfilesView {
const ProfilesView({required this.profiles, required this.connectionsByProfile, required this.connectionsById});
static const empty = ProfilesView(profiles: [], connectionsByProfile: {}, connectionsById: {});
int countFor(Profile profile) {
if (profile.isPlexHome) return profile.parentConnectionId == null ? 0 : 1;
return connectionsByProfile[profile.id]?.length ?? 0;
}
}
/// Join-table rows that should be shown as explicit, user-manageable
+18 -10
View File
@@ -11,6 +11,15 @@ 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'}) {
return JellyfinConnection(
id: id,
@@ -71,7 +80,7 @@ void main() {
group('ConnectionRegistry', () {
test('list() returns empty when no connections stored', () async {
expect(await registry.list(), isEmpty);
expect(await registry.getDefault(), isNull);
expect(await _defaultConnectionId(db), isNull);
});
test('first upserted connection becomes the default', () async {
@@ -80,8 +89,7 @@ void main() {
expect(list.length, 1);
expect(list.first.id, 'a');
final defaultConn = await registry.getDefault();
expect(defaultConn?.id, 'a');
expect(await _defaultConnectionId(db), 'a');
});
test('upsert preserves type discriminator (Plex vs Jellyfin)', () async {
@@ -141,10 +149,10 @@ void main() {
await registry.upsert(_jellyfin(id: 'b'));
// First is default by default; explicitly switch to b.
await registry.setDefault('b');
expect((await registry.getDefault())?.id, 'b');
expect(await _defaultConnectionId(db), 'b');
// Switch back to a.
await registry.setDefault('a');
expect((await registry.getDefault())?.id, 'a');
expect(await _defaultConnectionId(db), 'a');
});
test('remove deletes a row and re-elects a default when needed', () async {
@@ -153,10 +161,10 @@ void main() {
// a is default (first one in).
await registry.remove('a');
// b should now be the default.
expect((await registry.getDefault())?.id, 'b');
expect(await _defaultConnectionId(db), 'b');
// Removing the last clears the default cleanly.
await registry.remove('b');
expect(await registry.getDefault(), isNull);
expect(await _defaultConnectionId(db), isNull);
});
test('re-upsert preserves the existing default flag', () async {
@@ -165,15 +173,15 @@ void main() {
// wrote `isFirst` (false on update).
await registry.upsert(_jellyfin(id: 'a'));
await registry.upsert(_jellyfin(id: 'b'));
expect((await registry.getDefault())?.id, 'a');
expect(await _defaultConnectionId(db), 'a');
// Re-upsert the default with refreshed credentials.
await registry.upsert(_jellyfin(id: 'a', userName: 'refreshed'));
expect((await registry.getDefault())?.id, 'a');
expect(await _defaultConnectionId(db), 'a');
// And re-upserting a non-default row doesn't accidentally promote it.
await registry.upsert(_jellyfin(id: 'b', userName: 'refreshed'));
expect((await registry.getDefault())?.id, 'a');
expect(await _defaultConnectionId(db), 'a');
});
test('recordAuthSuccess updates lastAuthenticatedAt without losing config', () async {
+4 -4
View File
@@ -58,10 +58,10 @@ class _AppDatabaseTestSuite {
test('ProfileConnections has no profile_id FK (virtual plex_home profiles)', () async {
// v20 dropped the profile_id FK so virtual Plex Home profiles can
// persist join rows without a parent `profiles` row. The two
// profile-delete sites (profile_detail_screen, profile_switch_screen)
// call ProfileConnectionRegistry.removeAllForProfile manually before
// deleting the profile, so the cascade isn't needed.
// persist join rows without a parent `profiles` row. Profile deletion
// instead cleans up join rows explicitly (via the teardown flow's
// removeAllProfileConnectionsAndCleanup) before deleting the profile,
// so the cascade isn't needed.
final now = DateTime.now().millisecondsSinceEpoch;
await db
.into(db.connections)
@@ -144,16 +144,40 @@ void main() {
expect(await registry.listForConnection('c1'), isEmpty);
});
test('removeAllForProfile drops every row for a profile', () async {
test('removeAllForConnection re-promotes a default for a surviving profile', () async {
await registry.upsert(
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't', userIdentifier: 'u'),
makeDefault: true,
);
await registry.upsert(
const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't', userIdentifier: 'u'),
);
final removed = await registry.removeAllForProfile('p1');
expect(removed, 2);
expect(await registry.listForProfile('p1'), isEmpty);
// c1 is default; removing it connection-wide must leave p1 with c2 as
// its new default rather than defaultless.
await registry.removeAllForConnection('c1');
final remaining = await registry.listForProfile('p1');
expect(remaining, hasLength(1));
expect(remaining.single.connectionId, 'c2');
expect(remaining.single.isDefault, isTrue);
});
test('promoteMissingDefaults repairs a profile the FK cascade left defaultless', () async {
await registry.upsert(
const ProfileConnection(profileId: 'p1', connectionId: 'c1', userToken: 't', userIdentifier: 'u'),
makeDefault: true,
);
await registry.upsert(
const ProfileConnection(profileId: 'p1', connectionId: 'c2', userToken: 't', userIdentifier: 'u'),
);
// Deleting the connection cascades away p1's default join row (c1) via
// the connectionId FK, silently leaving p1 with no default flag.
await (db.delete(db.connections)..where((t) => t.id.equals('c1'))).go();
final beforeRepair = await registry.listForProfile('p1');
expect(beforeRepair.single.connectionId, 'c2');
expect(beforeRepair.single.isDefault, isFalse);
await registry.promoteMissingDefaults();
expect((await registry.listForProfile('p1')).single.isDefault, isTrue);
});
test('upsert succeeds for a virtual plex_home profile id with no parent row', () async {