Skip to content

Commit

Permalink
Add docs, fix some issues and improve some APIS (#4)
Browse files Browse the repository at this point in the history
Co-authored-by: Bruno Ferrari <[email protected]>
  • Loading branch information
mirland and Bruno Ferrari authored Aug 26, 2022
1 parent bb3addc commit bc6f9c8
Show file tree
Hide file tree
Showing 22 changed files with 327 additions and 121 deletions.
1 change: 1 addition & 0 deletions lib/errors.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/// Used to notify unexpected errors
class StockError extends Error {
final String message;

Expand Down
26 changes: 26 additions & 0 deletions lib/fetcher.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,37 @@
import 'package:stock/src/factory_fetcher.dart';
import 'package:stock/store.dart';
import 'package:stock/store_response.dart';

/// Fetcher is used by [Store] to fetch network records of the type [Output]
/// for a given key of the type [Key]. The return type is [Stream] to
/// allow for multiple results per request.
///
/// Note: Store does not catch exceptions thrown by a [Fetcher].
/// Use [StoreResponseError] to communicate expected errors.
///
/// See [ofFuture] for easily translating from a regular `Future` function.
/// See [ofStream], for easily translating to [StoreResponse] (and
/// automatically transforming exceptions into [StoreResponseError].
class Fetcher<Key, Output> {
/// "Creates" a [Fetcher] from a [futureFactory] and translates the results into a [StoreResponse].
///
/// Emitted values will be wrapped in [StoreResponseData]. If an exception disrupts the stream then
/// it will be wrapped in [StoreResponseError]
///
/// Use when creating a [Store] that fetches objects in a single response per request
/// network protocol (e.g Http).
static Fetcher<Key, Output> ofFuture<Key, Output>(
Future<Output> Function(Key key) futureFactory,
) =>
FutureFetcher(futureFactory);

/// "Creates" a [Fetcher] from a [streamFactory] and translates the results into a [StoreResponse].
///
/// Emitted values will be wrapped in [StoreResponseData]. If an exception disrupts the flow then
/// it will be wrapped in [StoreResponseError].
///
/// Use when creating a [Store] that fetches objects in a multiple responses per request
/// network protocol (e.g Web Sockets).
static Fetcher<Key, Output> ofStream<Key, Output>(
Stream<Output> Function(Key key) streamFactory,
) =>
Expand Down
81 changes: 62 additions & 19 deletions lib/source_of_truth.dart
Original file line number Diff line number Diff line change
@@ -1,43 +1,86 @@
import 'dart:async';

import 'package:flutter/widgets.dart';
import 'package:stock/fetcher.dart';
import 'package:stock/src/key_value.dart';
import 'package:stock/src/source_of_truth_impl.dart';
import 'package:stock/store.dart';
import 'package:stock/store_extensions.dart';

class SourceOfTruth<Key, Output> {
Stream<Output?> Function(Key key) reader;
Future<void> Function(Key key, Output? output) writer;
///
/// [SourceOfTruth], as its name implies, is the persistence API which [Store] uses to serve values to
/// the collectors. If provided, [Store] will only return values received from [SourceOfTruth] back
/// to the collectors.
///
/// In other words, values coming from the [Fetcher] will always be sent to the [SourceOfTruth]
/// and will be read back via [reader] to then be returned to the collector.
///
/// This round-trip ensures the data is consistent across the application in case the [Fetcher] does
/// not return all fields or returns a different class type than the app uses. It is particularly
/// useful if your application has a local observable database which is directly modified by the app,
/// as Store can observe these changes and update the collectors even before value is synced to the
/// backend.
///
/// [SourceOfTruth] takes care of making any source (no matter if it has flowing reads or not) into
/// a common flowing API.
///
/// A source of truth is usually backed by local storage. Its purpose is to eliminate the need
/// for waiting on a network update before local modifications are available (via [Store.stream]).
///
/// For maximal simplicity, [writer]'s record type ([T]] and [reader]'s record type
/// ([T]) are identical. However, sometimes reading one type of objects from network and
/// transforming them to another type when placing them in local storage is needed.
/// For this case you can use the [mapTo] and [mapToUsingMapper] extensions.
///
abstract class SourceOfTruth<Key, T> {
/// Creates a source of truth that is accessed via [reader] and [writer].
///
/// The [reader] function is used to read records from the source of truth
/// The [writer] function is used to write records to the source of truth
factory SourceOfTruth({
required Stream<T?> Function(Key key) reader,
required Future<void> Function(Key key, T? output) writer,
}) =>
SourceOfTruthImpl(reader, writer);

SourceOfTruth({required this.reader, required this.writer});
/// Used by [Store] to read records from the source of truth for the given [key].
Stream<T?> reader(Key key);

/// Used by [Store] to write records **coming in from the fetcher (network)** to the source of
/// truth.
///
/// **Note:** [Store] currently does not support updating the source of truth with local user
/// updates (i.e writing record of type [T]). However, any changes in the local database
/// will still be visible via [Store.stream] APIs as long as you are using a local storage that
/// supports observability (e.g. Floor, Drift, Realm).
Future<void> write(Key key, T? value);
}

// A memory cache implementation of a [SourceOfTruth], which stores the latest value and notify new ones.
class CachedSourceOfTruth<Key, T> implements SourceOfTruth<Key, T> {
final _streamController = StreamController<T?>.broadcast();
final _streamController = StreamController<KeyValue<Key, T?>>.broadcast();

late Map<Key, T?> _cachedValues;

@override
late Stream<T?> Function(Key key) reader;
@override
late Future<void> Function(Key key, T? output) writer;

CachedSourceOfTruth([Map<Key, T?>? cachedValues]) {
_cachedValues = {if (cachedValues != null) ...cachedValues};
reader = generateReader;
writer = generateWriter;
}

@protected
@visibleForTesting
void setCachedValue(Key key, T? t) => _cachedValues[key] = t;
void setCachedValue(Key key, T? value) => _cachedValues[key] = value;

@protected
Stream<T?> generateReader(Key key) async* {
@override
Stream<T?> reader(Key key) async* {
yield _cachedValues[key];
yield* _streamController.stream;
yield* _streamController.stream
.where((event) => event.key == key)
.map((event) => event.value);
}

@protected
Future<void> generateWriter(Key key, T? value) async {
@override
Future<void> write(Key key, T? value) async {
setCachedValue(key, value);
_streamController.add(value);
_streamController.add(KeyValue(key, value));
}
}
6 changes: 6 additions & 0 deletions lib/src/key_value.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class KeyValue<Key, Value> {
final Key key;
final Value value;

KeyValue(this.key, this.value);
}
26 changes: 26 additions & 0 deletions lib/src/source_of_truth_impl.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import 'package:stock/source_of_truth.dart';

class SourceOfTruthImpl<Key, T> implements SourceOfTruth<Key, T> {
final Stream<T?> Function(Key key) _reader;
final Future<void> Function(Key key, T? output) _writer;

SourceOfTruthImpl(this._reader, this._writer);

@override
Stream<T?> reader(Key key) => _reader(key);

@override
Future<void> write(Key key, T? value) => _writer(key, value);
}

class WriteWrappedSourceOfTruth<Key, T> extends CachedSourceOfTruth<Key, T> {
final SourceOfTruth<Key, T>? _realSourceOfTruth;

WriteWrappedSourceOfTruth(this._realSourceOfTruth);

@override
Future<void> write(Key key, T? value) async {
await _realSourceOfTruth?.write(key, value);
await super.write(key, value);
}
}
4 changes: 2 additions & 2 deletions lib/src/store_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import 'package:stock/source_of_truth.dart';
import 'package:stock/src/extensions/future_stream_extensions.dart';
import 'package:stock/src/extensions/store_response_extensions.dart';
import 'package:stock/src/factory_fetcher.dart';
import 'package:stock/src/wrapped_source_of_truth.dart';
import 'package:stock/src/source_of_truth_impl.dart';
import 'package:stock/store.dart';
import 'package:stock/store_request.dart';
import 'package:stock/store_response.dart';
Expand Down Expand Up @@ -104,7 +104,7 @@ class StoreImpl<Key, Output> implements Store<Key, Output> {
await _writingLock
.protect(() async => _incrementWritingMap(request, 1));
var writerResult = await sourceOfTruth
?.writer(request.key, response.value)
?.write(request.key, response.value)
.mapToResponse(ResponseOrigin.fetcher);
if (writerResult is StoreResponseError) {
dataStreamController.add(writerResult.swapType());
Expand Down
13 changes: 0 additions & 13 deletions lib/src/wrapped_source_of_truth.dart

This file was deleted.

28 changes: 21 additions & 7 deletions lib/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,30 @@ import 'package:stock/source_of_truth.dart';
import 'package:stock/src/store_impl.dart';
import 'package:stock/store_response.dart';

abstract class Store<Key, Output> {
/// A [Store] is responsible for managing a particular data request.
///
/// When you create an implementation of a [Store], you provide it with a [Fetcher], a function that defines how data will be fetched over network.
///
/// This [SourceOfTruth] is either a real database or an in memory source of truth.
/// Its purpose is to eliminate the need for waiting on a network update before local modifications are available (via [Store.stream]).
///
abstract class Store<Key, T> {
factory Store({
required Fetcher<Key, Output> fetcher,
required SourceOfTruth<Key, Output>? sourceOfTruth,
required Fetcher<Key, T> fetcher,
required SourceOfTruth<Key, T>? sourceOfTruth,
}) =>
StoreImpl<Key, Output>(fetcher: fetcher, sourceOfTruth: sourceOfTruth);
StoreImpl<Key, T>(fetcher: fetcher, sourceOfTruth: sourceOfTruth);

Stream<StoreResponse<Output>> stream(Key key, {refresh = true});
/// Returns a [Stream] for the given key.
/// [refresh] is used to ensure a fresh value from the [Fetcher],
/// If it's `false`, [Store] will try to return the [SourceOfTruth] data,
/// and if it doesn't exist, [Store] will request fresh data using the [Fetcher].
Stream<StoreResponse<T>> stream(Key key, {refresh = true});

Future<Output> fresh(Key key);
/// Helper factory that will return fresh data for [key] while updating your cache
Future<T> fresh(Key key);

Future<Output> get(Key key);
/// Returns data for [key] if it is cached otherwise will return
/// fresh/network data (updating your cache).
Future<T> get(Key key);
}
28 changes: 16 additions & 12 deletions lib/store_extensions.dart
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import 'source_of_truth.dart';
import 'package:stock/type_mapper.dart';

abstract class StoreConverter<T0, T1> {
T1 fromT0(T0 t0);
import 'source_of_truth.dart';

T0 fromT1(T1 t1);
}
extension SourceOfTruthExtensions<Key, Input> on SourceOfTruth<Key, Input> {
/// Transforms a [SourceOfTruth] of [Key], [Input] into a [SourceOfTruth] of [Key], [Output].
SourceOfTruth<Key, Output> mapToUsingMapper<Output>(
StoreTypeMapper<Input, Output> mapper,
) =>
mapTo(mapper.fromInput, mapper.fromOutput);

extension SourceOfTruthExtensions<Key, Output1> on SourceOfTruth<Key, Output1> {
SourceOfTruth<Key, Output2> mapTo<Output2>(
StoreConverter<Output1, Output2> converter,
/// Transforms a [SourceOfTruth] of [Key], [Input] into a [SourceOfTruth] of [Key], [Output].
SourceOfTruth<Key, Output> mapTo<Output>(
Output Function(Input) fromInput,
Input Function(Output) fromOutput,
) =>
SourceOfTruth<Key, Output2>(
reader: (key) => reader(key)
.map((value) => value == null ? null : converter.fromT0(value)),
SourceOfTruth<Key, Output>(
reader: (key) =>
reader(key).map((value) => value == null ? null : fromInput(value)),
writer: (key, value) =>
writer(key, value == null ? null : converter.fromT1(value)),
write(key, value == null ? null : fromOutput(value)),
);
}
12 changes: 12 additions & 0 deletions lib/store_request.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
import 'package:stock/fetcher.dart';
import 'package:stock/source_of_truth.dart';
import 'package:stock/store.dart';

/// Represents a single store request
///
/// The [key] is a unique identifier for your data
///
/// If [refresh] is `true`, [Store] will always get fresh value from fetcher.
/// If it's `false`, [Store] will try to return the [SourceOfTruth] data,
/// and if it doesn't exist, [Store] will request fresh data using the [Fetcher].
///
class StoreRequest<Key> {
Key key;
bool refresh;
Expand Down
9 changes: 3 additions & 6 deletions lib/store_response.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import 'package:stock/store.dart';
/// Holder for responses from Store.
///
/// Instead of using regular error channels (a.k.a. throwing exceptions), Store uses this holder
/// class to represent each response. This allows the [Stream] to keep running even if an error happens
/// so that if there is an observable single source of truth, application can keep observing it.
/// class to represent each response. This allows the [Stream] to keep flowing even if an error happens
/// so that if there is an observable single source of truth, the application can keep observing it.
class StoreResponse<Output> {
final ResponseOrigin origin;

const StoreResponse._(this.origin);

/// Loading event dispatched by [Store] to signal the [Fetcher] is in progress.
/// Loading event dispatched by [Store] to signal the [Fetcher] is currently running.
const factory StoreResponse.loading(ResponseOrigin origin) =
StoreResponseLoading<Output>;

Expand Down Expand Up @@ -97,9 +97,6 @@ class StoreResponseError<T> extends StoreResponse<T> {

/// Represents the origin for a [StoreResponse].
enum ResponseOrigin {
/// [StoreResponse] is sent from the cache
cache,

/// [StoreResponse] is sent from the [SourceOfTruth]
sourceOfTruth,

Expand Down
7 changes: 7 additions & 0 deletions lib/type_mapper.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// Converts the [Input] type to the [Output] and vice versa.
/// Used to transform DB entity to network entity and vice versa.
abstract class StoreTypeMapper<Input, Output> {
Output fromInput(Input value);

Input fromOutput(Output value);
}
3 changes: 1 addition & 2 deletions scripts/checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ echo "$result"

flutter pub run dart_code_metrics:metrics check-unused-code lib --fatal-unused || { echo -e "${RED}Linter error" ; exit 1; }

# TODO: Enable it
# flutter pub run dart_code_metrics:metrics check-unused-files lib --fatal-unused || { echo -e "${RED}Linter error" ; exit 1; }
flutter pub run dart_code_metrics:metrics check-unused-files lib --fatal-unused || { echo -e "${RED}Linter error" ; exit 1; }

echo ':: Run tests ::'
flutter test || { echo -e "${RED}Test error" ; exit 1; }
Loading

0 comments on commit bc6f9c8

Please sign in to comment.