Files
Client/lib/api/request_cache.dart
T

168 lines
4.9 KiB
Dart

import 'dart:async';
import 'dart:convert';
import '../model/account_data.dart';
import 'api_response.dart';
import 'cache_store.dart';
import 'errors/parse_exception.dart';
import 'errors/stale_session_exception.dart';
abstract class RequestCache<T extends ApiResponse?> {
static const int cacheNothing = 0;
static const int cacheMinute = 60;
static const int cacheHour = 60 * 60;
static const int cacheDay = 60 * 60 * 24;
int maxCacheTime;
void Function(T)? onUpdate;
/// Called only when [start] finds a cached payload in the [CacheStore]. Use this
/// (instead of [onUpdate]) when callers need to distinguish stale-but-fast
/// cache hits from authoritative network responses.
void Function(T)? onCacheData;
/// Called only when [start] receives a fresh payload from the network.
void Function(T)? onNetworkData;
void Function(Exception) onError;
bool? renew;
final Completer<void> _ready = Completer<void>();
/// Resolves when [start] has finished, regardless of whether the network
/// call succeeded, failed, or was skipped due to a fresh cache. Callers
/// can await this to know when both the cache lookup and the network
/// attempt have settled.
Future<void> get ready => _ready.future;
RequestCache(
this.maxCacheTime,
this.onUpdate, {
this.onError = ignore,
this.renew = false,
this.onCacheData,
this.onNetworkData,
});
static void ignore(Exception e) {}
Future<void> start(String document) async {
final epoch = AccountData().sessionEpoch;
try {
final entry = await CacheStore.instance.read(document);
var lastUpdate = entry?.lastUpdate ?? 0;
T? cached;
if (entry != null) {
try {
cached = fromCacheJson(await CacheStore.decode(entry.json));
} on Object {
// A payload from an older model version: treat it as a miss.
await CacheStore.instance.delete(document);
lastUpdate = 0;
}
}
if (cached != null) {
onUpdate?.call(cached);
onCacheData?.call(cached);
}
await _load(document, epoch, lastUpdate: lastUpdate);
} finally {
if (!_ready.isCompleted) _ready.complete();
}
}
Future<void> _load(
String document,
int epoch, {
required int lastUpdate,
}) async {
if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) <
lastUpdate) {
if (renew == null || !renew!) return;
}
try {
final newValue = await onLoad();
// The cache is shared, so a late response of a signed-out
// account would otherwise be cached for the next one.
if (!AccountData().isCurrentSession(epoch)) {
onError(const StaleSessionException());
return;
}
onUpdate?.call(newValue);
onNetworkData?.call(newValue);
unawaited(CacheStore.instance.write(document, jsonEncode(newValue)));
} on Exception catch (e) {
onError(e);
}
}
T fromCacheJson(Map<String, dynamic> json);
Future<T> onLoad();
}
/// Concrete [RequestCache] that takes the two overrides as constructor
/// callbacks instead of requiring a subclass per endpoint.
class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
final Future<T> Function() _loader;
final T Function(Map<String, dynamic> json) _fromJson;
SimpleCache({
required int cacheTime,
required Future<T> Function() loader,
required T Function(Map<String, dynamic> json) fromJson,
void Function(T)? onUpdate,
void Function(T)? onCacheData,
void Function(T)? onNetworkData,
void Function(Exception)? onError,
bool? renew,
}) : _loader = loader,
_fromJson = fromJson,
super(
cacheTime,
onUpdate,
onError: onError ?? RequestCache.ignore,
renew: renew,
onCacheData: onCacheData,
onNetworkData: onNetworkData,
);
@override
Future<T> onLoad() => _loader();
@override
T fromCacheJson(Map<String, dynamic> json) => _fromJson(json);
}
/// Captures the latest cache payload (cached or network) and rethrows the
/// captured network error if no payload arrived. Collapses the
/// `latest`/`capturedError`/`await ready` boilerplate that DataProviders
/// otherwise repeat per endpoint.
Future<T> resolveFromCache<T extends ApiResponse?>(
RequestCache<T> Function(
void Function(T) onUpdate,
void Function(Exception) onError,
)
build, {
void Function(Object)? onError,
String? operationName,
}) async {
T? latest;
Object? capturedError;
final cache = build((data) => latest = data, (e) {
capturedError = e;
onError?.call(e);
});
await cache.ready;
final err = capturedError;
// `latest` may still hold the cache hit read before the sign-out.
if (err is StaleSessionException) throw err;
if (latest != null) return latest as T;
if (err != null) throw err;
throw ParseException(
technicalDetails: operationName != null
? 'No data and no error from $operationName'
: null,
);
}