Skip to content

Repositories

View raw .md

Repository<T> is an id-keyed cache of one remote resource's rows, Eloquent's identity map for a client with no query builder; RepositoryQuery<T> is one ordered, filtered, paginated view over it.

On this page

Introduction#

A list screen and a show screen for the same resource used to keep two separate copies of the same row, one per MagicStateMixin; a patch on one never reached the other. Repository<T> holds one row per id, and every RepositoryQuery<T> built over it reads that row live, so a patch or an evict shows up everywhere at once, with no refetch.

Writing a Repository#

Subclass Repository<T> with the three pieces that describe the resource:

dart
class MonitorRepository extends Repository<Monitor> {
  static MonitorRepository instance = MonitorRepository();

  @override
  String get resource => 'monitors';

  @override
  Monitor Function(Map<String, dynamic>) get fromMap => Monitor.fromMap;
}

Never register a Repository via Magic.put/Magic.findOrPut: its constructor registers it with SessionScope on its own, which is how a session reset reaches it (see Session Resets). A plain static singleton, as above, is the right shape.

RepositoryQuery#

RepositoryQuery<T> owns the ordering, the cursor, and the filters over a repository; the rows themselves live in the repository. It reuses MagicPaginator.fetcher to walk pages, and writes every page it reads into the repository via Repository.upsertFromList.

dart
final query = RepositoryQuery<Monitor>(
  repository: MonitorRepository.instance,
  perPage: 50,
  filters: {'status': statusFilter},
);

await query.ensureFresh(); // joins the first reload if one is already in flight
query.items;               // live rows, in server order
query.hasMore;
query.isLoadingMore;
await query.loadMore();
await query.setFilters({'status': 'down'}); // reloads from the first page

Built by the controller that owns it, one per screen; dispose it from that controller's onClose:

dart
class MonitorListController extends MagicController {
  late final query = RepositoryQuery<Monitor>(repository: MonitorRepository.instance);

  @override
  void onClose() {
    query.dispose();
    super.onClose();
  }
}

showOnlyKeys#

A list endpoint often does not measure every field a show endpoint does (uptime_24h, a computed aggregate). Override showOnlyKeys to name those fields: a null the list sends for one of them keeps whatever the cache already holds from a prior upsertFromShow, instead of erasing it.

dart
class MonitorRepository extends Repository<Monitor> {
  @override
  Set<String> get showOnlyKeys => const {'uptime_24h'};
}

A field outside showOnlyKeys that the list sends as null DOES become null: the list is the source of truth for everything it was not told to leave alone. upsertFromShow is authoritative for every field regardless, since the show endpoint measures them all.

Reading and Writing a Single Row#

Method What it does
find(id) The cached row for id, or null
all Every cached row, in no particular order
refresh(id) Re-reads GET $resource/$id; a 404 evicts the row and answers null, any other failure hands back the cached copy
patch(id, attributes) Merges attributes onto the cached row; a no-op when nothing is cached for id
evict(id) Drops the cached row, if any

refresh's 404-evicts, other-failure-keeps split matters: a fault of ours (a timeout, a 500) is never a verdict about the row, so only a confirmed "this row is gone" clears the cache.

Every write path notifies its listeners (and so every RepositoryQuery over it) only when the cache actually changed: a row that was not cached, an evicted row, or an attribute whose stored value differs, compared deeply and with map keys in any order. A broadcast patch that restates what is cached, or a refresh that answers the row already held, rebuilds nothing. upsertFromShow still stores the answered instance, so find(id) is the row refresh returned. A controller that needs to repaint once a read settles (to clear its own loading flag) calls its own refreshUI() after the await; it cannot count on the repository's notify, which a failed read never sent either. A RepositoryQuery reload is unaffected: its page landing notifies through the paginator whether or not the rows changed.

Session Resets#

A Repository implements SessionScoped and registers itself with SessionScope in its own constructor, so a login or a team switch drops every cached row before the owning controller refetches (see Session Scope). A RepositoryQuery built over it registers its own reset hook through Repository.addResetHook, clearing its cursor, its filters' resolved ids, and its isFirstLoad/hasMore flags alongside the rows, so a screen mid-load when the identity changes shows its first-load skeleton for the new session rather than a stale page.

Failure Modes#

Situation What happens
A list page throws mid-fetch RepositoryQuery.items and .meta keep what they already held; loadFailed is true only when items is also empty
A page lands after a session reset (repository.epoch moved) The page's rows never reach the repository or the query's ids
refresh(id) gets a 404 The row is evicted; answers null
refresh(id) gets a 500 or times out The cached row stands; answers the cached copy
patch(id, ...) called for an id nothing has fetched No-op: a partial patch never fabricates a row nobody fetched

Generating a Repository#

bash
dart run magic:dowel make:repository Monitor            # -> MonitorRepository
dart run magic:dowel make:repository MonitorRepository  # Suffix already present
dart run magic:dowel make:repository Monitor --force    # Overwrite existing file
esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.