A generic, offline-first sync engine for Dart & Flutter.
Register any model, write locally, synchronize through any backend — against any local database.
Super Sync doesn't know what a Todo, User or Message is. It understands
only entities, ids, operations, payloads, versions and cursors — so the
same engine syncs every model in your app through one API, against any backend,
on top of any local database.
final sync = SuperSync(
store: DriftSyncLocalStore(db), // or sqflite / in-memory — your choice
remote: MySyncRemote(), // your backend, behind one interface
)
..register(TodoSyncAdapter())
..register(UserSyncAdapter());
await sync.start();
final todos = sync.repository<Todo>();
await todos.save(const Todo(id: 't1', title: 'Buy milk')); // optimistic, instant
todos.watchAll(); // reactive stream- Why
- How it fits together
- The monorepo
- Quick start
- Choosing a local database
- Code generation from OpenAPI
- Built to scale
- Architecture
- Examples
- Development
- License
- Generic. One engine, every model. Adding a model is a ~10-line adapter — the engine code never changes.
- Backend-agnostic. REST, GraphQL, Supabase, Firebase, a Cloudflare Worker —
a backend is supported the moment you implement
SyncRemote(two methods). - Database-agnostic. The local store is an interface. Use the bundled in-memory store, Drift/SQLite, or sqflite — or implement your own over Hive, Isar, ObjectBox, etc.
- Offline-first. Writes hit local storage and an outbox first; the UI updates immediately and the network catches up.
- Built to scale, not just to demo — keyset pagination, chunked pull, paged push, outbox coalescing, exponential backoff + dead-letter, and a monotonic-apply guard. Details below.
You implement (or generate) two small things and pick a store. The engine does the rest.
your model your backend
│ │
SyncEntityAdapter<T> SyncRemote
(type/id/encode/decode) (push / pull)
│ │
└──────────► SyncEngine ◄────┘
│
SyncLocalStore
│
┌────────────────────┼────────────────────┐
InMemory… Drift (SQLite) sqflite
(tests/proto) (reactive, default) (popular plugin)
- A
SyncEntityAdapter<T>is the only thing a model implements:type,idOf,encode,decode. - A
SyncRemoteis the single backend seam:push(mutations)andpull(cursor). - A
SyncLocalStoreis the persistence seam — pick one of the bundled stores or write your own. - The
SyncEngine(behind theSuperSyncfacade) orchestrates the outbox, batching, conflict resolution, pagination and retries.
A Dart pub workspace (native resolution: workspace, one lockfile) of
independently publishable packages:
| Package | Role | Status |
|---|---|---|
super_sync |
Pure-Dart core engine — adapters, repository, engine, pager, in-memory store | ✅ |
super_sync_drift |
Durable Drift/SQLite store — reactive query streams for free (recommended default for persistence) | ✅ |
super_sync_sqflite |
Durable sqflite/SQLite store — for apps already on the most popular plugin | ✅ |
super_sync_codegen |
Generate the whole data layer (models + adapters + wiring) from an OpenAPI 3 spec | ✅ |
apps/pokedex |
Flutter macOS desktop demo — live PokeAPI pull into a persistent local DB, infinite scroll, stress test | ✅ |
super_sync_hive |
Pure-Dart Hive store (reactive watch()) |
planned |
super_sync_flutter |
App-lifecycle & connectivity sync triggers | planned |
Each package versions and releases on its own. Core and the two stores all pass
dart pub publish --dry-run with 0 warnings.
class TodoSyncAdapter implements SyncEntityAdapter<Todo> {
@override
String get type => 'todo';
@override
String idOf(Todo v) => v.id;
@override
Map<String, Object?> encode(Todo v) =>
{'id': v.id, 'title': v.title, 'done': v.done};
@override
Todo decode(Map<String, Object?> d) => Todo(
id: d['id']! as String,
title: d['title']! as String,
done: d['done']! as bool,
);
}abstract interface class SyncRemote {
Future<PushResponse> push(List<SyncMutation> mutations);
Future<PullResponse> pull({String? cursor, Set<String>? entityTypes, int? limit});
}push uploads a batch of mixed-type mutations and returns a per-mutation ack
(applied / conflict / rejected). pull returns one bounded page of changes
plus a cursor; the engine calls it until hasMore is false.
final sync = SuperSync(store: store, remote: remote)
..register(TodoSyncAdapter());
await sync.start();The same surface works for every registered type:
abstract interface class SyncRepository<T> {
Stream<List<T>> watchAll();
Stream<T?> watch(String id);
SyncPager<T> paged({int pageSize = 30});
Future<List<T>> getAll();
Future<T?> get(String id);
Future<void> save(T value);
Future<void> saveAll(List<T> values);
Future<void> delete(String id);
Future<void> sync();
}Conflicts are detected by version (baseVersion vs the server revision), so
the engine never depends on clock agreement between devices. The strategy is a
global default (serverWins / clientWins), overridable per entity with a
custom ConflictResolver.
The local store is the SyncLocalStore interface — every bundled store behaves
identically (same monotonic-apply guard, outbox coalescing and keyset
pagination). They differ only in persistence and how reactivity is delivered.
| Store | Package | Persistent | Reactivity | Best for |
|---|---|---|---|---|
InMemorySyncLocalStore |
super_sync |
❌ | broadcast controllers | tests, prototypes |
DriftSyncLocalStore |
super_sync_drift |
✅ SQLite | native query streams | most apps (recommended) |
SqfliteSyncLocalStore |
super_sync_sqflite |
✅ SQLite | manual change-notifier | apps already on sqflite |
Reactivity note. Drift gives reactive query streams out of the box, so
watchAllre-emits with zero extra machinery. sqflite has no query streams, so that store re-implements reactivity with a per-type change notifier (every write pokes a tick and the stream re-queries). Both are correct and smooth; Drift is the recommended default because it's free.
Swapping stores is a one-line change:
// in-memory → Drift
store: InMemorySyncLocalStore() → store: DriftSyncLocalStore(db)
// in-memory → sqflite
store: InMemorySyncLocalStore() → store: SqfliteSyncLocalStore(db)Want Hive, Isar, ObjectBox or raw SQLite? Implement SyncLocalStore and you're
done — the engine is unchanged.
Every store keeps data in generic tables (sync_records, sync_mutations,
sync_cursors) holding JSON payloads. Adding or changing a model is never a
database migration — re-generate or edit your adapter and ship.
Point super_sync_codegen at an OpenAPI 3 spec
and get your entire data layer as one file:
dart run super_sync_codegen:super_sync_gen openapi.yaml lib/app_models.g.dartFor every schema with an id property it emits an immutable model, a
SyncEntityAdapter, a single registerSuperSyncModels(sync) call, and an
AppSync facade with a typed repository per model. Then the whole layer comes up
in one call — over any store:
final app = await openAppSync(
store: DriftSyncLocalStore(db), // or SqfliteSyncLocalStore / InMemory…
remote: MyRemote(),
);
await app.todos.save(const Todo(id: 't1', title: 'Buy milk', completed: false));
app.users.watchAll();
app.status.listen(print);
await app.syncNow();| Concern | How |
|---|---|
| Large lists | keyset pagination — O(page size) at any depth |
| Large initial sync | chunked pull drains page by page in one cycle |
| Large outbox | paged push in bounded batches |
| Edit bursts | per-entity outbox coalescing → one push, not one per keystroke |
| Flaky network | exponential backoff + dead-letter ceiling |
| Out-of-order delivery | monotonic-apply guard |
| Local edits vs server | un-pushed local work is never clobbered by a pull |
| Concurrency | single-flight sync via an async Lock — no overlapping cycles |
These aren't claims — they're verified. The demo app pulls live data into a persistent DB, survives restart (loads instantly with no network), and a stress test runs 200 concurrent writes + 3 concurrent syncs with consistent counts and no deadlock.
Core concepts (in packages/super_sync/lib/src):
SyncRecord— the generic envelope:type,id, JSONdata, a monotoniclocalVersion, aserverVersion, adeletedtombstone,updatedAt.SyncMutation+SyncOperation— an outbox entry (create/update/delete) with an idempotency key andbaseVersionfor conflict detection.SyncEntityAdapter<T>— the per-model seam (type/idOf/encode/decode).SyncRemote— the backend seam (push/pull).SyncLocalStore— the persistence seam (records + outbox + cursor + GC, with keyset paging).SyncEngine— paged conflict-aware push with backoff/dead-letter, chunked local-wins-skip pull, single-flight viapackage:synchronized.SyncPager<T>— keyset + reactive window for smooth infinite scroll.SuperSync— the facade:register<T>/start/repository<T>/sync/status/purgeTombstones.
Design choices: version-based conflicts (not clock/LWW — web-int safe and no clock dependency); writes are atomic optimistic record + outbox enqueue; un-pushed local work is skipped on pull so the server can't clobber it; outbox coalescing (create+delete of an unsynced entity is a net no-op).
packages/super_sync/example— console demo syncing against the live PokeAPI (read-onlySyncRemote, keyset pull, local cache, reactive pager).apps/pokedex— Flutter macOS desktop app: live pull into a Drift DB, infinite-scroll grid, status bar, and a concurrency stress button.
flutter pub get # resolves the whole workspace (includes the Flutter app)
# format / analyze / test the libraries
dart format --output=none --set-exit-if-changed packages/*/lib packages/*/test
dart analyze packages/super_sync packages/super_sync_drift packages/super_sync_sqflite packages/super_sync_codegen
dart test packages/super_sync
dart test packages/super_sync_drift
dart test packages/super_sync_sqflite
dart test packages/super_sync_codegenNote: use
flutter pub get(notdart pub get) at the root — the workspace contains a Flutter app, so the Dart-only resolver can't satisfy it. CI does the same viasubosito/flutter-action.
Lints are very_good_analysis.
See CONTRIBUTING.md and CODE_OF_CONDUCT.md.
Apache-2.0 © androidpoet (Ranbir Singh)