Repositories
Repository is an id-keyed cache of one remote resource's rows, Eloquent's identity map for a client with no query builder; RepositoryQuery is one ordered, filtered, paginated view over it.
- Introduction
- Writing a Repository
- RepositoryQuery
- showOnlyKeys
- Reading and Writing a Single Row
- Session Resets
- Failure Modes
- Generating a Repository
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 holds one row per id, and every RepositoryQuery 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 with the three pieces that describe the resource:
class MonitorRepository extends Repository {
static MonitorRepository instance = MonitorRepository();
@override
String get resource => 'monitors';
@override
Monitor Function(Map) 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 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.
final query = RepositoryQuery(
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:
class MonitorListController extends MagicController {
late final query = RepositoryQuery(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.
class MonitorRepository extends Repository {
@override
Set 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 doc/digging-deeper/session-scope.md). 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
dart run magic:artisan make:repository Monitor # -> MonitorRepository
dart run magic:artisan make:repository MonitorRepository # Suffix already present
dart run magic:artisan make:repository Monitor --force # Overwrite existing file