LogoSkills

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.


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 restartable() Discard emissions from the superseded execution
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. Subscription cleanup is your responsibility (see below). concurrent() does not exist; omitting the transformer is the default.

// โœ… CORRECT: Apply restartable to SWR events
on<ItemsLoad>(_onLoad, transformer: restartable());

// โœ… 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 and cancel it in close(). An onError handler is still required.

// โœ… CORRECT: container-owned subscription + onError + close() cleanup
StreamSubscription<Either<Failure, List<Item>>>? _subscription;

Future<void> _onLoad(ItemsLoad event, void Function(ItemsState) emit) async {
  await _subscription?.cancel(); // re-subscribe cleanly

  _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));
    },
  );
}

@override
Future<void> close() async {
  if (isClosed) return;
  await _subscription?.cancel(); // โš ๏ธ required โ€” no automatic cleanup
  await super.close();
}

// โŒ WRONG: emit.forEach โ€” does not exist in bloc_signals
// โŒ WRONG: relying on restartable() to cancel the subscription (it only drops emissions)
// โŒ 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


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.