fiber_crucible 1.0.0 copy "fiber_crucible: ^1.0.0" to clipboard
fiber_crucible: ^1.0.0 copied to clipboard

The vocabulary a service is written in. Outcome, failure, async state, action, pagination and lifetime, with no logic of its own.

fiber_crucible #

The vocabulary a service is written in.

What it is #

Seven small types and one function, in pure Dart with no dependencies. They say what an outcome looks like, what a value a screen is waiting for looks like, and they carry the three pieces of bookkeeping every service ends up writing again by hand: the guard around an action, the state of a paged list, and the subscriptions to cancel.

It holds no logic of its own, and it knows nothing about what it carries. There is no cache here, no permission, no endpoint, no HTTP and no entity. Those are logic, and logic belongs to whoever writes the service.

Why use it #

Because a service layer written without one of these ends up with all of the following, and none of them are anybody's decision. They accumulate.

One error type per endpoint. A codebase this was measured on had 115 error enums for 110 operations. Eight variants accounted for almost all of them: unauthorized appeared 92 times, notPermitted 94, vpnRequired 97. The handful of genuinely operation-specific ones appeared once each, buried in the repetition. And the vocabulary had already drifted: the newest modules said rateLimited and forbidden where the older ones said tooManyRequests and notPermitted, which quietly broke the one function that decided what to retry.

Here there is one closed Fault, and Invalid(field, reason) is the single door the domain goes through.

One loading enum per service. The same codebase had seventeen, fourteen of them spelling { loading, paginating, ready, empty, network, failure } exactly. Three separate fields make states representable that mean nothing, and the classic one is a flag left down with the value still absent.

Here there is Async<T>, sealed on three cases, and the compiler asks for all three.

Pagination, rewritten per screen. Fourteen services each carried their own _offset, _hasMore, _isFetching and thirty-line _fetchPage. The guard against two overlapping reads is the part that gets left out.

Here there is Pager<T>, and its source is one closure, so it does not care where pages come from.

Subscriptions nobody cancels. .listen(...) written inline, with the subscription never kept. Nothing tears down when a session ends.

Here there is Scope, and cancel_subscriptions in the analyser catches the rest.

None of that is invention. Result is what the Flutter architecture guidance recommends, Async is the shape Riverpod's AsyncValue settled on, Command is Flutter's own command pattern, and Pager is the headless half of a PagingController. What this package does is put them in one place so they stop being written again.

What stops being possible #

What can no longer be written What prevents it
a service method that throws answer catches everything, including what it does not know
a state that is neither a value nor an error reaching a screen Async is sealed on three cases
an Ok or an Err built by hand the constructors are private, answer is the only way
a subject or a bare stream in a public signature Mutable stays private, Observable is read-only
a subscription that leaks Scope.own holds them and close cancels them
an incomplete switch over a failure Fault is sealed
a failure case invented quietly Invalid is the only door

Getting it #

dependencies:
  fiber_crucible: ^1.0.0
import 'package:fiber_crucible/fiber_crucible.dart';

The pieces #

Seven files, each one subject. This is all of them.

answer and Result<T> #

The outcome of something asked once. Ok carries the value, Err carries the Fault saying why there is none.

Future<Result<Store>> get(String storeId) => answer(() async {
  if (!permission.readStore.value) throw const NotPermitted();

  final cached = await dao.get(storeId);
  if (cached != null) return cached.convert();

  if (!network.isReachable) throw const Offline();
  return (await refresh(storeId)) ?? (throw const NotFound());
});

answer runs any closure at all. It does not know what the body calls or what it returns; it only knows a failure is expressed by throwing a Fault. Anything else thrown becomes Unknown, so a method written this way never throws.

The body reads top to bottom as ordinary logic. Nothing here decides for you when to hit the cache or when to give up.

Reading one:

switch (await store.get(id)) {
  case Ok(:final value): show(value);
  case Err(:final fault): report(fault);
}

valueOrNull, faultOrNull, isOk and map are there for when a switch is more than the situation deserves.

Fault and Reason #

Why there is no value. Nine transport variants, which any call can produce whatever it does: Unauthorized, NotPermitted, VpnRequired, NotFound, Conflict, TooLarge, RateLimited, Offline, Unknown.

Then the one door your domain has into it:

enum StoreField { title, description, schedules, brands }

if (title.trim().isEmpty) throw const Invalid(StoreField.title, Reason.empty);
if (title.length > 120)   throw const Invalid(StoreField.title, Reason.tooLong);

field is a member of an enum your own module declares, and reason comes from the shared Reason enum. One screen turns the pair into a message without knowing which resource produced it, and a refusal decided by the server arrives in the same shape as one decided locally.

Retrying is a property of the type rather than a string comparison on names:

if (fault.isTransient) queue.replayLater(event);

isTransient is a switch that names every variant and has no default. Add one and this package stops compiling until somebody says whether it is worth retrying. That is the point of the set being closed.

Observable<T> and Mutable<T> #

A value that is always there, and its changes. Mutable is the writing half, kept private; Observable is what a service hands out.

class NetworkService {
  final Mutable<bool> _reachable = Mutable<bool>(true);

  Observable<bool> get reachable => _reachable.observable;

  void onProbe(bool value) => _reachable.set(value);
}
if (network.reachable.value) send();              // no waiting for a first event
network.reachable.stream.listen(onChanged);       // and current from then on

stream replays the value standing when it is listened to, so a screen that subscribes late is never left with nothing until the next change. set emits every time, including for an equal value; a listener that only wants changes writes stream.distinct().

follow hands the subscription to a Scope:

_reachable.follow(probe.results, scope: scope);

Async<T> and Local<T> #

The state of a value a screen is waiting for. Three cases, and no fourth.

final Mutable<Async<Store>> _store = Mutable(const Loading<Store>());

Future<void> load(String id) async {
  _store.set(Async.of(await service.get(id)));
}
switch (state.value) {
  case Loading():            return const Spinner();
  case Data(:final value):   return StoreView(value);
  case Failed(:final fault): return ErrorView(fault);
}

Async.of turns a Result into it. valueOrNull and isLoading are there for the cases where a switch is too much.

Two states that look like they belong here do not. Whether a list is empty is read off the list, and whether another page is loading belongs to the Pager. Neither is a state of the value itself, and storing them is what lets them drift out of step with it.

Local<T> is separate, and it answers a different question: has the server agreed to this yet?

Local(store, isSynced: false)   // written offline, not pushed yet

Nothing here builds one. The logic that reads the local store decides what isSynced is worth.

Command0 and Command1 #

An action a screen triggers, and where its execution stands.

final publish = Command1<void, String>(store.publish);
final state = publish.state.value;

if (state.running) return const Spinner();
if (state.result case Err(:final fault)) return ErrorBanner(fault);

return Button(onPressed: () => publish.run(storeId));

The guard is one line at the start of the run: a second run while the first is still going returns immediately, so a double press cannot publish twice. That is the part that gets written by hand and forgotten.

clear() forgets the last outcome once it has been shown, so a rebuild does not show the same failure again. Command0 is the same thing for an action that takes nothing.

Page<T> and Pager<T> #

A list read one page at a time.

final pager = Pager<StorePreview>((offset) => answer(() async {
  final page = await sdk.store.page(offset: offset);
  await dao.addManyFromServer(page.items);
  return Page(items: page.items, offset: page.offset, hasMore: page.hasMore);
}));

await pager.next();       // read the next page
await pager.reset();      // drop everything and read the first again
switch (pager.items.value) {
  case Loading():           return const Spinner();
  case Data(:final value):  return StoreList(value, more: pager.paginating.value);
  case Failed(:final fault): return ErrorView(fault);
}

The offset, the is-there-more, and the guard against two overlapping reads all live here. The source is one closure, so the pager does not know whether pages come from the network, a local store, or the store first and the network only when it comes up short.

Two behaviours were decided rather than fallen into. A page that fails on top of items already shown leaves them in place, because losing a screenful of results costs more than a list that stopped growing. And seed publishes items without asking the source at all:

pager.seed(cached, offset: cached.length, hasMore: true);

That is how a local cache answers first: the screen shows something straight away, and next then decides whether the source needs asking.

paginating only emits when it turns over, so a screen can listen to it without filtering.

Scope #

The subscriptions a service opens, cancelled together.

class BrandService {
  final Scope _scope = Scope();

  void onUserChanged(String? userId) {
    _scope.close();
    if (userId == null) return;

    _scope
      ..own(dao.watchAll().listen(_items.set))
      ..own(events.updated.listen(_onUpdated));
  }
}

A service that listens has to remember to stop, and the listener opened inline is the one that never gets cancelled. Handing every subscription to a scope turns that from something to remember into something to call once.

A scope stays usable after close, which is exactly what a service needs when one session ends and another begins.


Running the examples #

Everything above is in example/, as a Flutter app with one screen per piece. It is the place to see what a failure actually looks like on a screen, what the pager keeps when a page fails, and that tapping a command twice runs it once.

cd example
flutter run

The backend it talks to is fake, slow on purpose, and fails about one call in four, which is what makes the screens worth looking at.

Working on it #

bash tool/test.sh runs everything a pull request has to pass: resolve, analyse, format, the suite, a publication dry run, and the example.

CONTRIBUTING.md says where work goes and how a version comes out, STYLE.md says what the code looks like, TESTING.md says when a test earns its place.

Licence #

Mozilla Public License 2.0. See LICENSE, and the header on every source file.

1
likes
160
points
118
downloads

Documentation

API reference

Publisher

verified publisherfiberstudio.app

Weekly Downloads

The vocabulary a service is written in. Outcome, failure, async state, action, pagination and lifetime, with no logic of its own.

Repository (GitHub)
View/report issues
Contributing

License

MPL-2.0 (license)

More

Packages that depend on fiber_crucible