Skip to content
dev-ceteraPublic

About

An offline-first, secure data repository with automatic sync, retry, and encryption for Dart & Flutter.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

pub tag instagram license


Summary

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 CacheStrategy modes (cacheOrElseNetwork, networkOrElseCache, cacheOnly, networkOnly) give you the right freshness vs. responsiveness trade-off per call.
  • Server-push integration. applyServerChange / applyServerDelete let SSE/WebSocket handlers feed into the same cache without enqueueing fake actions.
  • AES-256 at rest. Pluggable ReliableEncryption interface; the bundled ReliableAesEncryption uses 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.

Installation

flutter pub add reliable

reliable is a Flutter package (it ships Flutter-only adapters for path_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.

Quick start

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 heartbeat

A runnable Flutter demo with an "Online/Offline" toggle lives in example/.

Storage backends

Adapter Best for Notes
SembastAdapter General-purpose, all platforms (mobile, desktop, web) Default choice. Cross-platform via conditional imports. Reopens a dead database connection (see below).
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';

Dead-connection recovery (SembastAdapter)

After iOS Safari backgrounds a tab, WebKit can kill the IndexedDB connection under a live page, and every cache read and write then fails until a reload. SembastAdapter recognises that failure, closes and reopens the database with the arguments of the first init, and retries the operation once. Operations failing together share one reopen, and one arriving mid-reopen waits for it. Recovery gives up for the session (recoveryGaveUp) when a reopen fails, takes longer than reopenTimeout (default 5 s), or is followed straight away by another dead-connection failure; from then on errors propagate at once.

final storage = SembastAdapter(
  dbName: 'app.db',
  reopenTimeout: const Duration(seconds: 5),
  // Optional: open a different factory, e.g. databaseFactoryMemory in tests.
  // openDatabase: ({required dbName, required version, codec, required onVersionChanged}) => ...,
);

Signing out while reads are in flight

A read writes its answer into the cache when it lands. If purgeCollections, clearUserData, or setActiveUser switching to another user ran in the meantime, the answer belongs to the previous user, so reliable does not cache it (the caller still receives it). A purge that arrives part-way through writing a list stops the write and clears what had landed.

Encryption

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.

Local cache migrations

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 toVersion order. Every cached doc's version is tracked under ReliableSchema.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_FAILED audit 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.

Using Dio (or any HTTP client) without a hard dependency

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, and leave statusCode null only when the request got no answer at all (offline, dropped, timed out) — the repository logs that as a warning, not an error, so don't wrap a bug in a status-less exception. 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. For a list read, reliable unwraps a bare array, {data: [...]} (or {data: {...}} for one document), and {items: [...]}; a null body reads as an empty list. Any other shape throws a FormatException naming the keys it saw — never a silent empty list, which would leave networkOrElseCache serving stale cache forever.

Working with envelope-style responses

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.


💬 Contributing and Discussions

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.

☝️ Ways you can contribute

  • Raise a ticket: Open an issue on the GitHub repository for questions, bugs and feature requests.
  • Get in touch: Email hello@robertsolves.com.au if you would like to contribute or collaborate.
  • 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!

👤 Author & Contact

LICENSE

This project is released under the MIT License. See LICENSE for more information.

About

An offline-first, secure data repository with automatic sync, retry, and encryption for Dart & Flutter.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages