Client Cache Conventions
Client cache architecture rules for this project.
Client cache architecture rules for this project.
Refer to the /client-cache skill for detailed implementation guide.
⚠️ Subscription axis — on conflict the swr-pattern skill wins. Every re-subscribe path below needs the per-slot generation token of skill §9-7 (unibook #13475 / PR #13478), and
restartable()is not part of it (skill §9-4) — it gates emissions only and cancels nothing.
3 Cache Strategies#
| Strategy | UseCase Return Type | Entry Point | Selection Criteria |
|---|---|---|---|
| SWR | Stream<Either<Failure, T>> |
watchStream() |
GET lists, real-time, externally mutable |
| CacheFirst | Future<Either<Failure, T>> |
execute() |
GET single, offline first |
| NetworkFirst | Future<Either<Failure, T>> |
execute() |
Payment/auth, always need latest |
Automatic Strategy Selection Rules#
GET 목록 → SWR → Stream<Either<Failure, List<T>>>
GET 단일 → CacheFirst or NetworkFirst → Future<Either<Failure, T>>
POST/PUT/DELETE → 직접 호출 (캐시 없음) + 관련 캐시 무효화
BlocSignal Transformer Rules#
Transformers are built into bloc_signals — do not import bloc_concurrency.
| Event Type | transformer | Reason |
|---|---|---|
| SWR Stream subscription | (omit) | ⛔ Never restartable() — the generation token performs the restart (skill §9-4) |
| Write operations (POST/PUT/DELETE) | droppable() |
Prevent duplicate submissions |
| Transactional ordering | sequential() |
FIFO via Mutex |
| Independent parallel requests | (omit) | Default behavior |
⚠️
bloc_signals'restartable()discards state emissions from superseded executions — unlikepackage:bloc, it does not cancel the handler's stream subscription, and it does not stop the handler body either (that runs to completion). Subscription cleanup is your responsibility (see below).concurrent()does not exist; omitting the transformer is the default.⛔ Never attach it to a handler that opens a subscription. Its
executionTokenlives on theon<E>registration, not on your subscription slot (bloc_signals1.2.0lib/src/concurrency/event_transformers.dart—var executionTokensits outside the returned closure), and it stays superseded permanently. So one registration driving several keys — or a handler that returns early — silences a still-live subscription's emits forever, without cancelling it. With a generation token in place the superseded run steps aside before any emit, sorestartable()has nothing left to discard: net effect zero, silent-regression risk above zero. It stays correct on handlers that onlyawaitand open no subscription (search-as-you-type, polling).
// ⛔ WRONG: restartable() on a handler that opens an SWR subscription (#13475)
on<ItemsLoad>(_onLoad, transformer: restartable());
// ✅ CORRECT: no transformer — the generation token owns the restart
on<ItemsLoad>(_onLoad);
// ✅ CORRECT: Apply droppable to write events
on<ItemCreate>(_onCreate, transformer: droppable());
SWR Stream Subscription Rules#
⚠️
emit.forEachdoes not exist inbloc_signals— it was apackage:blocEmitterAPI. The previous rule ("manual StreamSubscription management prohibited") is inverted: an explicitly owned subscription is now the required pattern, because nothing cleans it up automatically.
When integrating with an SWR UseCase (call() returns Stream), hold the subscription on the
container, guard the re-subscribe path with a per-slot generation token, and cancel it in
close(). An onError handler is still required.
await _subscription?.cancel(); alone is not enough. add() invokes the handler synchronously and
unawaiteds its Future (bloc_signals 1.2.0 bloc_signal_mixin.dart), so two runs of the same handler
can both pass that await and each open a subscription — the later assignment then overwrites the
earlier one without cancelling it, and the orphan keeps feeding stale data into the state
(unibook #13475 / PR #13478).
// ✅ CORRECT: owned subscription + generation token + onError + close() cleanup
StreamSubscription<Either<Failure, List<Item>>>? _subscription;
int _itemsGen = 0; // one counter per subscription slot (#13475)
Future<void> _onLoad(ItemsLoad event, void Function(ItemsState) emit) async {
// ① Claim the slot before the first await: take the generation, then detach.
// Detach BEFORE awaiting — a late resume must not null out a newer registration.
final generation = ++_itemsGen;
final previous = _subscription;
_subscription = null;
await previous?.cancel();
// ② A newer run took the slot while we awaited — step aside, do NOT subscribe.
if (isClosed || generation != _itemsGen) return;
// `late final` is safe here — `onDone` cannot fire before `listen` returns.
late final StreamSubscription<Either<Failure, List<Item>>> subscription;
subscription = const GetItemsUseCase().call(GetItemsParams(...)).listen(
(result) {
if (isClosed) return; // close() does not cancel in-flight work
emit(result.fold(
(failure) => stateValue.copyWith(
status: LoadingStatus.error,
failure: failure,
),
(items) => stateValue.copyWith(
status: LoadingStatus.loaded,
items: items,
),
));
},
onError: (Object _, StackTrace __) {
if (isClosed) return;
emit(stateValue.copyWith(status: LoadingStatus.error));
},
onDone: () {
// ⑤ Deregister only while the slot still holds THIS subscription.
if (identical(_subscription, subscription)) _subscription = null;
},
cancelOnError: false,
);
// ③ `emit()` and `listen()` above are synchronous — they can re-dispatch the same
// event, or close the container. Re-check before registering.
// ⚠️ The canonical `SwrBlocSignalMixin` checks only the generation here;
// `isClosed` is an extra guard this hand-rolled form adds.
if (isClosed || generation != _itemsGen) {
await subscription.cancel();
return;
}
// ④ Never overwrite a live subscription.
final stale = _subscription;
if (stale != null && !identical(stale, subscription)) {
unawaited(stale.cancel()); // dart:async
}
_subscription = subscription;
}
@override
Future<void> close() async {
if (isClosed) return;
final subscription = _subscription;
_subscription = null;
// `super.close()` flips `isClosed` synchronously (bloc_signals
// `CubitSignalMixin.close()` sets it right after its re-entry guard, before any
// `await`), so call it BEFORE awaiting the cancel — otherwise an in-flight `_onLoad`
// resumes with `isClosed == false` and opens a subscription nobody cancels.
await super.close();
await subscription?.cancel(); // ⚠️ required — no automatic cleanup
}
// ❌ WRONG: emit.forEach — does not exist in bloc_signals
// ❌ WRONG: relying on restartable() to cancel the subscription (it only drops emissions)
// ❌ WRONG: `await _subscription?.cancel();` then `listen(...)` with no generation token —
// overlapping runs each open one and the loser is never cancelled (#13475)
// ❌ WRONG: writing the slot AFTER the await (`await _sub?.cancel(); _sub = null;`) —
// a late resume erases a newer registration, orphaning it from close()
// ❌ WRONG: cancelling the subscription BEFORE `await super.close()` in close()
// ❌ WRONG: omitting the close() override → leaked subscription
// ❌ WRONG: .last loses SWR benefits (ignores cache)
final result = await useCase.call(params).last;
// ❌ WRONG: .first ignores server refresh (receives cache only)
final result = await useCase.call(params).first;
Anti-pattern details: See
swr-pattern.md§8. The overlap/zombie axis is swr-pattern skill §9-7 and the transformer rule is its §9-4.
cacheKey Format#
'{domain}__{usecase}__key:value'
- Separator: double underscore (
__) -
Pagination parameters (
page,size,limit,offset) not included - Filter parameters are nullable, included only when
!= null - Sort/order parameters must be included — omitting them returns the previous sort's cache on sort change (#6877/#6924)
-
User/permission scope segment (e.g.
pub:{publisherId}) when the response varies by requester — a shared key leaks another scope's data (#7893) - Date: Use
CacheQuery.fmtDate()(yyyy-MM-dd)
Example:
'schedule__get_schedule_data__start:2026-03-01__end:2026-03-31__class:5566'
'homework__get_homework_list__classId:42'
TTL (Cache Expiration Time)#
| Policy | TTL | Suitable For |
|---|---|---|
CachePolicies.realtime | 5 min | Notifications, attendance |
CachePolicies.standard | 10 min | Homework, schedule (default) |
CachePolicies.stable | 1 hour | Settings, products |
CachePolicies.immutable | 24 hours | Notices, terms |
Drift Table Rules#
Requirements#
- Row tables only (JSON blob prohibited)
- Do not set
primaryKey(use rowid) cacheKey+cachedAtcolumns required- Column names must match domain entity field names
- For nested structures, include parent entity fields as columns in each row
// ✅ CORRECT: 행 테이블, primaryKey 없음
class HomeworkItems extends Table {
TextColumn get cacheKey => text()();
TextColumn get homeworkId => text()();
TextColumn get title => text()();
DateTimeColumn get cachedAt => dateTime()();
// No primaryKey (use rowid)
}
// ❌ WRONG: JSON blob
class HomeworkDetailItems extends Table {
TextColumn get cacheKey => text()();
TextColumn get json => text()(); // JSON 금지
@override
Set<Column> get primaryKey => {cacheKey}; // primaryKey 설정 금지
}
upsert prohibited -> DELETE + INSERT#
// ✅ CORRECT: DELETE then INSERT
Future<void> saveItems(List<XxxCompanion> companions, String cacheKey) async {
await (delete(table)..where((t) => t.cacheKey.equals(cacheKey))).go();
await batch((b) => b.insertAll(table, companions));
}
// ❌ WRONG: upsert prohibited
await into(table).insertOnConflictUpdate(companion);
DB Migration: deleteTable + createTable#
onUpgrade: (Migrator migrator, int from, int to) async {
for (final table in allTables) {
await migrator.deleteTable(table.actualTableName);
await migrator.createTable(table);
}
},
DAO Rules#
- Handle Drift CRUD only (no domain entity dependency)
- Accept only Companion for INSERT
- Entity <-> Companion conversion is CacheRepository's responsibility
CacheRepository Conversion Methods#
// ✅ CORRECT
static XxxEntity _toEntity(XxxItemData data) { ... }
static XxxCompanion _toCompanion(XxxEntity entity, String cacheKey) { ... }
// ❌ WRONG
static XxxEntity _dataToXxx(XxxItemData data) { ... }
UseCase Rules#
- All UseCases use Params class (when parameters exist)
- SWR UseCase: Does not implement UseCase interface,
_repositorygetter, returns Stream -
Future UseCase:
implements UseCase,repogetter, try-catch +Log.e - Unified OpenApiService (OpenApiClient prohibited)
Cache Invalidation After Write#
Delete related caches by prefix after POST/PUT/DELETE completion.
await dao.deleteByCacheKeyPrefix('schedule__get_memo');
await dao.deleteByCacheKeyPrefix('schedule__get_memos');
Structured alternative (kobic, Epic #6021): prefer repository-level
MutationRepositoryMixin.executeMutation(invalidatedPrefixes:)so invalidation cannot be forgotten per-mutation, and invalidation failures never mask an already-successful server mutation — see swr-pattern skill §15.LIKE wildcard caveat (#8334): implement
deleteByCacheKeyPrefixwith a string range comparison (>= prefix AND < prefix + '\uffff'), notlike('$prefix%')—_in cache keys is a single-char LIKE wildcard, somy_page__would also deletemyxpage__…keys.
Related Documents#
- Client Cache Skill - Detailed implementation guide
- SWR Pattern Skill - §9-4 transformer rule, §9-7 overlap/zombie invariants (#13475)
- SWR Pattern Rule
- Caching Patterns Detail
- BLoC Patterns