Offline First combines SQLite and a remote provider into one unified repository. And, optionally, a memory cache layer as the entry point. The remote provider could query Firebase or REST, hydrate the results to SQLite, and then deliver those SQLite results back to the app. In this way, the app functions identically when it's online or offline:
💡 You can change default behavior on a per-request basis using policy:
(e.g. get<Person>(policy: OfflineFirstUpsertPolicy.localOnly)
). This is available for delete
, get
, getBatched
, and upsert
.
Using unique identifiers, where:
can connect multiple providers. It is declared using a map between a local provider (key) and a remote provider (value). This is useful when a remote provider only includes unique identifiers (such as "id": 1
) of associations, the OfflineFirstRepository can lookup that instance from another source and deserialize into a complete model.
last_name
instead of lastName
.
For a concrete example, SQLite is the local data source and REST is the remote data source:
Given the API:
{ "assoc": {
// These don't have to map to SQLite columns.
// They can also be String uuids that SQLite considers unique
"id": 12345,
"ids": [12345, 6789]
}}
The association can be automatically mapped to SQLite (note the inclusion of data
; this will always be "data" as it specifies the in-progress deserialization):
@OfflineFirst(where: {'id' : "data['assoc']['id']"})
final Assoc assoc;
@OfflineFirst(where: {'id' : "data['assoc']['ids']"})
final List<Assoc> assoc;
@OfflineFirst(where:)
only applies to associations or iterable associations. If @OfflineFirst(where:)
is not defined, the model will attempt to be instantiated by the REST key that maps to the field.
@OfflineFirst(where:)
is defined, the @Rest|Graphql(toGenerator:)
generator will not feature the field unless a toRest
custom generator is defined OR only one pair is defined in the map.
When storing raw data is more optimal than storing it as an association, use the factory fromJson
or the method toJson
:
import 'dart:convert';
class Weight {
final int size;
final String unit;
Weight(this.size, this.unit);
factory Weight.fromJson(Map<String, dynamic> data) {
if (data == null || data.isEmpty) return null;
final size = double.parse(data.keys.first.toString() ?? '0');
return Weight(size, data.values.first);
}
Map<String, dynamic> toJson() => {'size': size, 'unit': unit};
}
.fromJson
always expects a single, unnamed parameter and a type for that parameter. Multiple parameters and not declaring a type are both unsupported.
Dart's enhanced enums can also be used to do custom serdes work. In addition to fromJson
and toJson
, the enum can use the provider name:
enum Direction {
up,
down;
factory Direction.fromRest(String direction) => direction == up.name ? up : down;
int toSqlite() => Direction.values.indexOf(this);
}
💡 from<ProviderName>
or to<ProviderName>
will be prioritized over fromJson
or toJson
which are prioritized over the provider annotation's enumAsString: true
.
When fromJson
and toJson
are too heavy handed, provider-specific factories or provider-specific functions can be used via OfflineFirstSerdes
. Instead of toJson
, specify the provider (such as toRest
). Instead of fromJson
, specify the provider (such as fromRest
).
import 'dart:convert';
class Weight extends OfflineFirstSerdes<Map<int, String>, String> {
final int size;
final String unit;
Weight(this.size, this.unit);
// A fromRest factory must be defined
factory Weight.fromRest(Map<String, dynamic> data) {
if (data == null || data.isEmpty) return null;
final size = double.parse(data.keys.first.toString() ?? '0');
return Weight(size, data.values.first);
}
// A fromSqlite factory must be defined
factory Weight.fromSqlite(String data) => Weight.fromRest(jsonDecode(data));
toRest() => {size: unit};
toSqlite() => jsonEncode(toRest());
}
OfflineFirstSerdes
should not be used when the managed data must be queried. Plainly, Brick does not support JSON searches.
Some regularly requested functionality doesn't exist in out-of-the-box Brick. This functionality does not exist in the core because it is dependent on remote data formatting outside the scope of Brick or it's non-essential. However, for convenience, these features are available in a mix-and-match support library. As this is not officially supported, please use caution determining if these mixins are applicable to your implementation.
Mixin | Description |
---|---|
DeleteAllMixin |
Adds methods #deleteAll and #deleteAllExcept |
DestructiveLocalSyncFromRemoteMixin |
Extends get requests to force resync the remoteProvider to the local providers (also covered by new method #destructiveLocalSyncFromRemote ) |
import 'package:brick_offline_first/mixins.dart';
class MyRepository extends OfflineFirstRepository with DeleteAllMixin {}
All requests to the remote provider in the repository first pass through a queue that tracks unsuccessful requests in a SQLite database separate from the one that maintains application models. Should the application ever lose connectivity, the queue will resend all upsert
ed requests that occurred while the app was offline. All requests are forwarded to an inner client.
The queue is automatically added to all OfflineFirstWithGraphqlRepository
s and OfflineFirstWithRestRepository
s. This means that a queue should not be used as the RestProvider
's client or GraphqlProvider
's link, however, the queue will use the remote provider's client as its inner client:
final client = RestOfflineQueueClient(
restProvider.client, // or http.Client()
"OfflineQueue",
);
DELETE
, PATCH
, POST
, and PUT
for REST. In GraphQL, query
and subscription
operations are ignored. Fetching requests are not worth tracking as the caller may have been disposed by the time the app regains connectivity.