diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index eef02aaf8..1f16c7bde 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -732,9 +732,8 @@ class AppDatabase extends _$AppDatabase { ); } if (from < 21) { - appLogger.i('Dropping unused Connections.isDefault and ApiCache.cachedAt columns (v21 migration)'); + appLogger.i('Dropping unused Connections.isDefault column (v21 migration)'); await m.alterTable(TableMigration(connections)); - await m.alterTable(TableMigration(apiCache)); } }, ); @@ -1324,13 +1323,14 @@ String _rescopePinnedPlexMetadataStatement({ WHERE grandparent_rating_key IS NOT NULL AND grandparent_rating_key != '' ) - INSERT INTO api_cache (cache_key, data, pinned) + INSERT INTO api_cache (cache_key, data, pinned, cached_at) SELECT DISTINCT metadata.server_id || $namespaceExpression || substr(source.cache_key, length(metadata.server_id) + 2), source.data, - source.pinned + source.pinned, + source.cached_at FROM download_metadata_ids AS metadata $ownerJoin JOIN api_cache AS source @@ -1342,7 +1342,8 @@ String _rescopePinnedPlexMetadataStatement({ $ownerFilter ON CONFLICT(cache_key) DO UPDATE SET data = excluded.data, - pinned = excluded.pinned + pinned = excluded.pinned, + cached_at = excluded.cached_at '''; Future _resolveProductionDatabaseFile() async { diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 5c33ea5e6..33fb3ea61 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -2023,6 +2023,18 @@ class $ApiCacheTable extends ApiCache type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _cachedAtMeta = const VerificationMeta( + 'cachedAt', + ); + @override + late final GeneratedColumn cachedAt = GeneratedColumn( + 'cached_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); static const VerificationMeta _pinnedMeta = const VerificationMeta('pinned'); @override late final GeneratedColumn pinned = GeneratedColumn( @@ -2037,7 +2049,7 @@ class $ApiCacheTable extends ApiCache defaultValue: const Constant(false), ); @override - List get $columns => [cacheKey, data, pinned]; + List get $columns => [cacheKey, data, cachedAt, pinned]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -2066,6 +2078,12 @@ class $ApiCacheTable extends ApiCache } else if (isInserting) { context.missing(_dataMeta); } + if (data.containsKey('cached_at')) { + context.handle( + _cachedAtMeta, + cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), + ); + } if (data.containsKey('pinned')) { context.handle( _pinnedMeta, @@ -2089,6 +2107,10 @@ class $ApiCacheTable extends ApiCache DriftSqlType.string, data['${effectivePrefix}data'], )!, + cachedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cached_at'], + )!, pinned: attachedDatabase.typeMapping.read( DriftSqlType.bool, data['${effectivePrefix}pinned'], @@ -2108,11 +2130,17 @@ class ApiCacheData extends DataClass implements Insertable { final String cacheKey; final String data; + /// When the row was last written ([ApiCacheSingleton.put] stamps it on + /// every store). Read by the fresh-cache-first playback metadata gate, + /// [ApiCacheSingleton.getIfFresh]. + final DateTime cachedAt; + /// Whether this item is pinned for offline access final bool pinned; const ApiCacheData({ required this.cacheKey, required this.data, + required this.cachedAt, required this.pinned, }); @override @@ -2120,6 +2148,7 @@ class ApiCacheData extends DataClass implements Insertable { final map = {}; map['cache_key'] = Variable(cacheKey); map['data'] = Variable(data); + map['cached_at'] = Variable(cachedAt); map['pinned'] = Variable(pinned); return map; } @@ -2128,6 +2157,7 @@ class ApiCacheData extends DataClass implements Insertable { return ApiCacheCompanion( cacheKey: Value(cacheKey), data: Value(data), + cachedAt: Value(cachedAt), pinned: Value(pinned), ); } @@ -2140,6 +2170,7 @@ class ApiCacheData extends DataClass implements Insertable { return ApiCacheData( cacheKey: serializer.fromJson(json['cacheKey']), data: serializer.fromJson(json['data']), + cachedAt: serializer.fromJson(json['cachedAt']), pinned: serializer.fromJson(json['pinned']), ); } @@ -2149,20 +2180,27 @@ class ApiCacheData extends DataClass implements Insertable { return { 'cacheKey': serializer.toJson(cacheKey), 'data': serializer.toJson(data), + 'cachedAt': serializer.toJson(cachedAt), 'pinned': serializer.toJson(pinned), }; } - ApiCacheData copyWith({String? cacheKey, String? data, bool? pinned}) => - ApiCacheData( - cacheKey: cacheKey ?? this.cacheKey, - data: data ?? this.data, - pinned: pinned ?? this.pinned, - ); + ApiCacheData copyWith({ + String? cacheKey, + String? data, + DateTime? cachedAt, + bool? pinned, + }) => ApiCacheData( + cacheKey: cacheKey ?? this.cacheKey, + data: data ?? this.data, + cachedAt: cachedAt ?? this.cachedAt, + 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, + cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt, pinned: data.pinned.present ? data.pinned.value : this.pinned, ); } @@ -2172,36 +2210,41 @@ class ApiCacheData extends DataClass implements Insertable { return (StringBuffer('ApiCacheData(') ..write('cacheKey: $cacheKey, ') ..write('data: $data, ') + ..write('cachedAt: $cachedAt, ') ..write('pinned: $pinned') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(cacheKey, data, pinned); + int get hashCode => Object.hash(cacheKey, data, cachedAt, pinned); @override bool operator ==(Object other) => identical(this, other) || (other is ApiCacheData && other.cacheKey == this.cacheKey && other.data == this.data && + other.cachedAt == this.cachedAt && other.pinned == this.pinned); } class ApiCacheCompanion extends UpdateCompanion { final Value cacheKey; final Value data; + final Value cachedAt; final Value pinned; final Value rowid; const ApiCacheCompanion({ this.cacheKey = const Value.absent(), this.data = const Value.absent(), + this.cachedAt = const Value.absent(), this.pinned = const Value.absent(), this.rowid = const Value.absent(), }); ApiCacheCompanion.insert({ required String cacheKey, required String data, + this.cachedAt = const Value.absent(), this.pinned = const Value.absent(), this.rowid = const Value.absent(), }) : cacheKey = Value(cacheKey), @@ -2209,12 +2252,14 @@ class ApiCacheCompanion extends UpdateCompanion { static Insertable custom({ Expression? cacheKey, Expression? data, + Expression? cachedAt, Expression? pinned, Expression? rowid, }) { return RawValuesInsertable({ if (cacheKey != null) 'cache_key': cacheKey, if (data != null) 'data': data, + if (cachedAt != null) 'cached_at': cachedAt, if (pinned != null) 'pinned': pinned, if (rowid != null) 'rowid': rowid, }); @@ -2223,12 +2268,14 @@ class ApiCacheCompanion extends UpdateCompanion { ApiCacheCompanion copyWith({ Value? cacheKey, Value? data, + Value? cachedAt, Value? pinned, Value? rowid, }) { return ApiCacheCompanion( cacheKey: cacheKey ?? this.cacheKey, data: data ?? this.data, + cachedAt: cachedAt ?? this.cachedAt, pinned: pinned ?? this.pinned, rowid: rowid ?? this.rowid, ); @@ -2243,6 +2290,9 @@ class ApiCacheCompanion extends UpdateCompanion { if (data.present) { map['data'] = Variable(data.value); } + if (cachedAt.present) { + map['cached_at'] = Variable(cachedAt.value); + } if (pinned.present) { map['pinned'] = Variable(pinned.value); } @@ -2257,6 +2307,7 @@ class ApiCacheCompanion extends UpdateCompanion { return (StringBuffer('ApiCacheCompanion(') ..write('cacheKey: $cacheKey, ') ..write('data: $data, ') + ..write('cachedAt: $cachedAt, ') ..write('pinned: $pinned, ') ..write('rowid: $rowid') ..write(')')) @@ -6808,6 +6859,7 @@ typedef $$ApiCacheTableCreateCompanionBuilder = ApiCacheCompanion Function({ required String cacheKey, required String data, + Value cachedAt, Value pinned, Value rowid, }); @@ -6815,6 +6867,7 @@ typedef $$ApiCacheTableUpdateCompanionBuilder = ApiCacheCompanion Function({ Value cacheKey, Value data, + Value cachedAt, Value pinned, Value rowid, }); @@ -6838,6 +6891,11 @@ class $$ApiCacheTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get pinned => $composableBuilder( column: $table.pinned, builder: (column) => ColumnFilters(column), @@ -6863,6 +6921,11 @@ class $$ApiCacheTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get pinned => $composableBuilder( column: $table.pinned, builder: (column) => ColumnOrderings(column), @@ -6884,6 +6947,9 @@ class $$ApiCacheTableAnnotationComposer GeneratedColumn get data => $composableBuilder(column: $table.data, builder: (column) => column); + GeneratedColumn get cachedAt => + $composableBuilder(column: $table.cachedAt, builder: (column) => column); + GeneratedColumn get pinned => $composableBuilder(column: $table.pinned, builder: (column) => column); } @@ -6921,11 +6987,13 @@ class $$ApiCacheTableTableManager ({ Value cacheKey = const Value.absent(), Value data = const Value.absent(), + Value cachedAt = const Value.absent(), Value pinned = const Value.absent(), Value rowid = const Value.absent(), }) => ApiCacheCompanion( cacheKey: cacheKey, data: data, + cachedAt: cachedAt, pinned: pinned, rowid: rowid, ), @@ -6933,11 +7001,13 @@ class $$ApiCacheTableTableManager ({ required String cacheKey, required String data, + Value cachedAt = const Value.absent(), Value pinned = const Value.absent(), Value rowid = const Value.absent(), }) => ApiCacheCompanion.insert( cacheKey: cacheKey, data: data, + cachedAt: cachedAt, pinned: pinned, rowid: rowid, ), diff --git a/lib/database/tables.dart b/lib/database/tables.dart index c9710afec..9076c5c29 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -9,6 +9,11 @@ class ApiCache extends Table { TextColumn get data => text()(); + /// When the row was last written ([ApiCacheSingleton.put] stamps it on + /// every store). Read by the fresh-cache-first playback metadata gate, + /// [ApiCacheSingleton.getIfFresh]. + DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)(); + /// Whether this item is pinned for offline access BoolColumn get pinned => boolean().withDefault(const Constant(false))(); diff --git a/lib/services/api_cache.dart b/lib/services/api_cache.dart index a2fc30044..355372bcf 100644 --- a/lib/services/api_cache.dart +++ b/lib/services/api_cache.dart @@ -141,7 +141,15 @@ abstract class ApiCache { Future put(ServerId serverId, String endpoint, Map data) async { final key = _buildKey(serverId, endpoint); final encoded = await tryIsolateRun(() => jsonEncode(data)); - await _db.into(_db.apiCache).insertOnConflictUpdate(ApiCacheCompanion(cacheKey: Value(key), data: Value(encoded))); + // The explicit stamp matters: on conflict the upsert only updates the + // companion's columns, so relying on the column default would leave a + // refreshed row carrying its original write time and [getIfFresh] would + // treat just-refetched data as stale. + await _db + .into(_db.apiCache) + .insertOnConflictUpdate( + ApiCacheCompanion(cacheKey: Value(key), data: Value(encoded), cachedAt: Value(DateTime.now())), + ); } Future deleteForServer(ServerId serverId) async { diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index 223ac45c7..3d1fe27d2 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -926,7 +926,7 @@ class _AppDatabaseTestSuite { db = AppDatabase.forTesting(NativeDatabase.memory()); } }); - test('v21 migration drops connections.is_default and api_cache.cached_at without losing rows', () async { + test('v21 migration drops connections.is_default without touching api_cache.cached_at', () async { await db.close(); final tempDir = await Directory.systemTemp.createTemp('plezy_db_v21_migration_test_'); final file = File('${tempDir.path}/plezy_downloads.db'); @@ -947,12 +947,11 @@ class _AppDatabaseTestSuite { createdAt: 1000, ), ); - await seeded.customStatement('INSERT INTO api_cache (cache_key, data, pinned) VALUES (?, ?, 1)', [ - 'srv:/library/metadata/1', - '{}', - ]); + await seeded.customStatement( + 'INSERT INTO api_cache (cache_key, data, pinned, cached_at) VALUES (?, ?, 1, 12345)', + ['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; @@ -965,7 +964,9 @@ class _AppDatabaseTestSuite { .map((row) => row.read('name')) .toSet(); expect(connectionColumns, isNot(contains('is_default'))); - expect(cacheColumns, isNot(contains('cached_at'))); + // cached_at is load-bearing (ApiCacheSingleton.getIfFresh); the + // migration must leave it, and its values, alone. + expect(cacheColumns, contains('cached_at')); final connection = await reopened.select(reopened.connections).getSingle(); expect(connection.id, 'c1'); @@ -973,6 +974,7 @@ class _AppDatabaseTestSuite { final cacheRow = await reopened.select(reopened.apiCache).getSingle(); expect(cacheRow.cacheKey, 'srv:/library/metadata/1'); expect(cacheRow.pinned, isTrue); + expect(cacheRow.cachedAt.millisecondsSinceEpoch ~/ 1000, 12345); // The drift table recreation must restore the kind index. final indexRows = await reopened