문서 목차
이 페이지에서목차

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#

StrategyUseCase Return TypeEntry PointSelection 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 TypetransformerReason
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 — unlike package: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 executionToken lives on the on<E> registration, not on your subscription slot (bloc_signals 1.2.0 lib/src/concurrency/event_transformers.dart — var executionToken sits 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, so restartable() has nothing left to discard: net effect zero, silent-regression risk above zero. It stays correct on handlers that only await and 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.forEach does not exist in bloc_signals — it was a package:bloc Emitter API. 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)#

PolicyTTLSuitable For
CachePolicies.realtime5 minNotifications, attendance
CachePolicies.standard10 minHomework, schedule (default)
CachePolicies.stable1 hourSettings, products
CachePolicies.immutable24 hoursNotices, terms

Drift Table Rules#

Requirements#

  • Row tables only (JSON blob prohibited)
  • Do not set primaryKey (use rowid)
  • cacheKey + cachedAt columns 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, _repository getter, returns Stream
  • Future UseCase: implements UseCase, repo getter, 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 deleteByCacheKeyPrefix with a string range comparison (>= prefix AND < prefix + '\uffff'), not like('$prefix%') — _ in cache keys is a single-char LIKE wildcard, so my_page__ would also delete myxpage__… keys.


검색어를 입력하면 문서를 찾습니다.

↑ ↓ 이동 · Enter 열기Esc 닫기