An offline-first, secure data repository for Flutter. You bring a NetworkAdapter (any REST-ish HTTP client) and pick a StorageAdapter (Sembast, sqflite, or SharedPreferences); ReliableRepository glues them together into an optimistic-write, cache-first read pipeline with retry, exponential backoff, AES-256 at rest, and a durable sync queue that survives app restarts.
- Optimistic writes. Mutations land in RAM + disk immediately and are queued for sync; the UI never blocks on the network.
- Cache-first reads. Four
CacheStrategymodes (cacheOrElseNetwork,networkOrElseCache,cacheOnly,networkOnly) give you the right freshness vs. responsiveness trade-off per call. - Server-push integration.
applyServerChange/applyServerDeletelet SSE/WebSocket handlers feed into the same cache without enqueueing fake actions. - AES-256 at rest. Pluggable
ReliableEncryptioninterface; the bundledReliableAesEncryptionuses a random IV per write. - User-scoped queueing. Set the active user and pending actions for other users are skipped until that user is active again.
flutter pub add reliable
reliableis a Flutter package (it ships Flutter-only adapters forpath_provider/shared_preferences/sqflite). Pure-Dart server code can't import it today — file an issue if you need a pure-Dart core split out.
import 'package:reliable/reliable.dart';
// 1. Implement NetworkAdapter for your HTTP client (Dio, http, custom, ...).
class MyApi implements NetworkAdapter {
@override
Future<dynamic> request(
String endpoint,
RequestMethod method, {
dynamic data,
Map<String, dynamic>? queryParams,
Map<String, String>? headers,
}) async {
// ...call your HTTP client and return the decoded body
}
}
// 2. Pick a storage backend, initialize, and construct the repository.
final storage = SembastAdapter(dbName: 'app.db');
await storage.init(version: 1, onUpgrade: (db, oldV, newV) async {});
final repo = ReliableRepository(api: MyApi(), storage: storage);
await repo.initialize(); // starts the 5s background sync heartbeat
// 3. Read with a cache strategy.
final todos = await repo.fetch(
'todos',
'/v1/todos',
strategy: CacheStrategy.NETWORK_OR_ELSE_CACHE,
);
// 4. Write optimistically — queues for sync, returns immediately.
await repo.write(
collection: 'todos',
endpoint: '/v1/todos',
method: RequestMethod.POST,
data: {'title': 'Buy milk'},
);
// 5. Inspect / drain the queue.
final pending = await repo.getPendingCount();
await repo.syncNow(); // force a sync attempt instead of waiting for the heartbeatA runnable Flutter demo with an "Online/Offline" toggle lives in example/.
| Adapter | Best for | Notes |
|---|---|---|
SembastAdapter |
General-purpose, all platforms (mobile, desktop, web) | Default choice. Cross-platform via conditional imports. |
SharedPreferencesAdapter |
Tiny caches, key-value-shaped data | Lightweight; not suitable for large collections. |
SqliteAdapter |
Mobile-only, large datasets needing SQL | Not exported from reliable.dart — sqflite pulls in dart:ffi, which breaks web builds. Import directly: import 'package:reliable/src/adapters/sqlite_adapter.dart'; |
Wrap any adapter with ReliableAesEncryption for transparent at-rest encryption:
final storage = SembastAdapter(dbName: 'app.db');
await storage.init(
version: 1,
onUpgrade: (db, oldV, newV) async {},
encryption: ReliableAesEncryption('your-32-character-secret-key!!!'),
);The key must be exactly 32 characters (AES-256). Each write uses a fresh random IV.
When your backend's response shape changes between app releases, locally-cached docs from older app versions still live on the user's device. Without migration, the new app code crashes parsing them. Register a DataMigration per collection and reliable brings stale docs forward on the next read:
final repo = ReliableRepository(
api: MyApi(),
storage: storage,
dataMigrations: {
'users': [
// v0 → v1: rename `firstName` → `name`.
DataMigration(
toVersion: 1,
migrate: (doc) => {
...doc,
'name': doc['firstName'],
}..remove('firstName'),
),
// v1 → v2: ensure `verified` is always present.
DataMigration(
toVersion: 2,
migrate: (doc) => {'verified': false, ...doc},
),
],
},
);- Migrations are forward-only and applied in ascending
toVersionorder. Every cached doc's version is tracked underReliableSchema.colSchemaVersion(a reserved internal field stripped from returned maps). - Freshly-fetched network docs are tagged with the latest version on write — they're assumed to already match the current schema.
- A migration that throws drops just that doc and emits a
REPO_MIGRATION_FAILEDaudit event; the rest of the collection still loads. The host can refetch from the network to recover. - Collections with no migrations registered cost nothing: the schema-version field is never written and reads never copy.
reliable does not depend on Dio, http, or any specific HTTP client. You provide a NetworkAdapter that wraps whatever your app already uses. Canonical Dio wrapper:
import 'package:dio/dio.dart';
import 'package:reliable/reliable.dart';
class DioNetworkAdapter implements NetworkAdapter {
DioNetworkAdapter(this.dio);
final Dio dio;
@override
Future<dynamic> request(
String endpoint,
RequestMethod method, {
dynamic data,
Map<String, dynamic>? queryParams,
Map<String, String>? headers,
}) async {
try {
final response = await dio.request<dynamic>(
endpoint,
data: data,
queryParameters: queryParams,
options: Options(method: method.name.toUpperCase(), headers: headers),
);
return response.data;
} on DioException catch (e) {
// 4xx (except 401/408/429) is permanent — don't waste queue retries on it.
final code = e.response?.statusCode;
final fatal = code != null &&
code >= 400 &&
code < 500 &&
code != 401 &&
code != 408 &&
code != 429;
throw ReliableNetworkException(
message: e.message ?? 'request failed',
statusCode: code,
isFatal: fatal,
);
}
}
}The translation rule that matters: convert client/protocol errors into ReliableNetworkException with isFatal set correctly. Fatal errors drop the action from the queue immediately; non-fatal errors get exponential backoff. Returning the decoded body (Map, List, or null) is the only other contract — reliable handles both raw lists and {data: [...]} envelopes.
If your endpoint returns the document as the payload (a translation map keyed by translation key, a flat config blob, etc.), use fetchOneRaw so the internally-injected id field doesn't pollute your lookup namespace:
final translations = await repo.fetchOneRaw(
'translations',
'/v1/translations',
id: 'en_US',
);
// translations is just {'hello': 'Hello', 'world': 'World', ...} — no synthetic 'id' key.For entity-shaped responses (the body has its own id), use fetchOne as normal.
🔍 For more information, refer to the API reference.
This is an open-source project, and we warmly welcome contributions from everyone, regardless of experience level. Whether you're a seasoned developer or just starting out, contributing to this project is a fantastic way to learn, share your knowledge, and make a meaningful impact on the community.
- Find us on Discord: Feel free to ask questions and engage with the community here: https://discord.gg/gEQ8y2nfyX.
- Share your ideas: Every perspective matters, and your ideas can spark innovation.
- Help others: Engage with other users by offering advice, solutions, or troubleshooting assistance.
- Report bugs: Help us identify and fix issues to make the project more robust.
- Suggest improvements or new features: Your ideas can help shape the future of the project.
- Help clarify documentation: Good documentation is key to accessibility. You can make it easier for others to get started by improving or expanding our documentation.
- Write articles: Share your knowledge by writing tutorials, guides, or blog posts about your experiences with the project. It's a great way to contribute and help others learn.
No matter how you choose to contribute, your involvement is greatly appreciated and valued!
If you're enjoying this package and find it valuable, consider showing your appreciation with a small donation. Every bit helps in supporting future development. You can donate here: https://www.buymeacoffee.com/dev_cetera
This project is released under the MIT License. See LICENSE for more information.