SyncDrift Flutter
Production-grade offline-first reactive database framework for Flutter, built on Drift.
One package. Zero compromise. Full offline–online parity.
SyncDrift is an opinionated, decorator-based persistence layer that wraps Drift/SQLite with:
- Stale-While-Revalidate (SWR) caching — sub-millisecond reads, background refresh
- Transactional offline queue — writes survive network loss, sync when back online
- Real-time inbound push — Supabase Realtime / WebSocket changes applied locally with loop-prevention
- File/media storage — local cache + offline upload queue with exponential backoff
- Code generation — type-safe repositories, relationship loaders, and Riverpod providers from annotated Drift tables
All in one import. No separate sub-packages to juggle.
Table of Contents
- Platform Support
- Packages
- Installation
- Architecture
- Quick Start
- Feature Deep Dive
- API Reference
- Configuration
- Versioning
- Contributing
- Security
- License
Platform Support
| Platform | Status |
|---|---|
| Android | ✅ Supported |
| iOS | ✅ Supported |
| macOS | ✅ Supported |
| Linux | ✅ Supported |
| Windows | ✅ Supported |
| Web | ⚠️ SQLite via WASM (no sqlite3_flutter_libs) |
Packages
This repository is a Melos-managed monorepo. End-users only need the two packages below:
| Package | Version | Description |
|---|---|---|
syncdrift_flutter |
^0.1.0 |
Core runtime: caching, sync, realtime, storage |
syncdrift_generator |
^0.1.0 |
Build-time code generator (dev dependency) |
syncdrift_annotations |
transitive | Annotations consumed by the generator (auto-resolved) |
Installation
Add the following to your app's pubspec.yaml:
dependencies:
syncdrift_flutter: ^0.1.0
dev_dependencies:
build_runner: ^2.4.9
drift_dev: ^2.20.0
syncdrift_generator: ^0.1.0
Then run:
flutter pub get
Note:
syncdrift_annotationsis a transitive dependency ofsyncdrift_flutterand does not need to be added manually.
Architecture
SyncDrift uses a decorator chain pattern. Each driver layer wraps the one below it, adding behaviour without modifying the core SQLite layer:
┌──────────────────────────────────────────────────────────┐
│ Your Flutter UI / Riverpod Widgets │
└───────────────────────┬──────────────────────────────────┘
│ reads & writes via generated repos
┌───────────▼───────────┐
│ CachedDatabaseDriver │ ← SWR cache, instant reads, TTL eviction
└───────────┬───────────┘
┌───────────▼───────────┐
│ SyncDatabaseDriver │ ← intercepts writes → SQLite FIFO queue
└───────────┬───────────┘
┌───────────▼───────────┐
│ DriftDatabaseDriver │ ← raw SQLite via Drift
└───────────────────────┘
│ outbound │ inbound
▼ ▼
SyncQueueProcessor RealtimeSyncManager
├── RestSyncAdapter ├── SupabaseRealtimeAdapter
└── SupabaseSyncAdapter └── WebSocketRealtimeAdapter
│ │
▼ ▼
Remote API Remote API
▲
SyncdriftStorageManager
├── RestStorageAdapter
└── SupabaseStorageAdapter
Key design principle: The runWithoutQueue() zone ensures real-time inbound writes are applied locally without re-triggering the outbound sync queue — preventing infinite feedback loops.
Quick Start
1. Annotate Your Tables
Use SyncDrift's annotations on your existing Drift table definitions:
import 'package:drift/drift.dart';
import 'package:syncdrift_flutter/syncdrift_flutter.dart';
@Repository()
@Cached(ttlSeconds: 60) // Enable SWR caching with 60-second TTL
@SyncTable() // Intercept writes for remote sync
@HasMany(Posts, foreignKey: 'userId')
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
TextColumn get email => text().withLength(max: 255)();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
@Repository()
@Cached(ttlSeconds: 30)
@SyncTable()
@BelongsTo(Users, foreignKey: 'userId')
class Posts extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get userId => integer().references(Users, #id)();
TextColumn get title => text().withLength(max: 200)();
TextColumn get body => text()();
DateTimeColumn get updatedAt => dateTime().nullable()();
}
2. Run Code Generation
dart run build_runner build --delete-conflicting-outputs
This emits database.syncdrift.dart alongside your Drift-generated file, providing:
| Generated Symbol | Type | Description |
|---|---|---|
UserRepository |
class |
Full CRUD + pagination + reactive watchAll() / watchOne() |
PostRepository |
class |
Includes loadUsers() relationship loader |
syncdriftCacheConfigurations |
Map<String, Duration> |
TTL map passed to CachedDatabaseDriver |
syncdriftSyncTables |
Set<String> |
Table names passed to SyncDatabaseDriver |
syncdriftDriverProvider |
Provider<DatabaseDriver> |
Riverpod provider for DI |
userRepositoryProvider |
Provider<UserRepository> |
Auto-generated per-table provider |
Watch mode (during development):
dart run build_runner watch --delete-conflicting-outputs
3. Wire Up in main.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/native.dart';
import 'package:syncdrift_flutter/syncdrift_flutter.dart';
import 'database.dart';
import 'database.syncdrift.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// ─── Layer 1: Core SQLite via Drift ──────────────────────────────────────
final db = AppDatabase(NativeDatabase.createInBackground(File('app.db')));
final rawDriver = DriftDatabaseDriver(db);
// ─── Layer 2: Outbound sync (REST or Supabase) ───────────────────────────
final syncAdapter = SupabaseSyncAdapter(supabase: Supabase.instance.client);
final syncDriver = SyncDatabaseDriver(
delegate: rawDriver,
syncAdapter: syncAdapter,
syncTables: syncdriftSyncTables, // generated
);
await syncDriver.init(); // creates pending_operations tables
// ─── Layer 3: SWR caching ────────────────────────────────────────────────
final cachedDriver = CachedDatabaseDriver(
delegate: syncDriver,
cacheManager: SyncdriftCacheManager(),
cacheConfigurations: syncdriftCacheConfigurations, // generated
);
// ─── Inbound: Real-time push sync ────────────────────────────────────────
final realtimeManager = RealtimeSyncManager(
dbDriver: syncDriver, // writes bypass outbound queue (loop prevention)
adapters: [
SupabaseRealtimeAdapter(supabase: Supabase.instance.client),
// WebSocketRealtimeAdapter(url: 'wss://api.example.com/ws'),
],
);
await realtimeManager.start();
// ─── File / media storage sync ───────────────────────────────────────────
final storageManager = SyncdriftStorageManager(
dbDriver: rawDriver,
storageAdapter: SupabaseStorageAdapter(supabase: Supabase.instance.client),
maxRetries: 5,
);
await storageManager.init();
// ─── Start background sync processor ─────────────────────────────────────
final syncProcessor = SyncQueueProcessor(
dbDriver: syncDriver,
syncAdapter: syncAdapter,
);
syncProcessor.init();
runApp(
ProviderScope(
overrides: [
// Inject the fully decorated driver into the Riverpod graph
syncdriftDriverProvider.overrideWith((ref) => cachedDriver),
],
child: const MyApp(),
),
);
}
4. Use in Widgets
// Reading data — reactive, cache-aware
class UsersScreen extends ConsumerWidget {
const UsersScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userRepo = ref.watch(userRepositoryProvider);
return StreamBuilder<List<Map<String, dynamic>>>(
stream: userRepo.watchAll(orderBy: 'created_at DESC'),
builder: (context, snapshot) {
if (!snapshot.hasData) return const CircularProgressIndicator();
final users = snapshot.requireData;
return ListView.builder(
itemCount: users.length,
itemBuilder: (_, i) => ListTile(
title: Text(users[i]['name'] as String),
subtitle: Text(users[i]['email'] as String),
),
);
},
);
}
}
// Writing data — auto-queued for remote sync when offline
Future<void> createUser(UserRepository repo, String name, String email) async {
await repo.create({'name': name, 'email': email});
// ↑ Writes to SQLite instantly. If online → syncs immediately.
// If offline → queued in pending_operations, drains on reconnect.
}
Feature Deep Dive
SWR Caching
SyncDrift implements the Stale-While-Revalidate caching strategy popularized by HTTP RFC 5861:
- Cache hit (fresh): Returns cached data immediately. No DB round-trip.
- Cache hit (stale): Returns stale data immediately and fires a background refresh.
- Cache miss: Fetches from SQLite, populates cache, returns result.
// TTL configured per-table via @Cached annotation
@Cached(ttlSeconds: 60) // Queries stale after 60 s
class Users extends Table { ... }
// DevTools inspection at runtime
// Register the VM Service extension and open Flutter DevTools → Extensions
// Dart: ext.syncdrift.cache.inspect — view all cache entries
// Dart: ext.syncdrift.cache.clear — flush cache
Benchmark (500 reads on a mid-range device):
| Mode | Time |
|---|---|
| Raw SQLite | ~350 ms |
| SyncDrift cache hit | ~50 ms |
| Speedup | ~7× |
Cache is automatically invalidated on any insert/update/delete to that table.
Offline Outbound Sync
Any write to a @SyncTable-annotated table is intercepted and enqueued atomically in SQLite before hitting the network:
INSERT users → SyncDatabaseDriver
├── delegate.insert(...) // write to SQLite ✓
└── pending_operations.insert // enqueue operation ✓
└── SyncQueueProcessor (background)
├── [online] → call SyncAdapter.sync() → DELETE from queue
└── [offline] → wait for connectivity_plus event → retry
Retry strategy: Exponential backoff — 2^retries seconds (1 s, 2 s, 4 s, 8 s, …). After maxRetries, the operation is moved to failed_operations for inspection.
// Implement your own backend by extending SyncAdapter
class MyApiSyncAdapter implements SyncAdapter {
@override
Future<void> sync(String table, String operation, Map<String, dynamic> payload) async {
// Map to your REST/GraphQL/gRPC calls
}
}
Built-in adapters:
| Adapter | Backend |
|---|---|
RestSyncAdapter |
Any HTTP REST API (multipart / JSON) |
SupabaseSyncAdapter |
Supabase PostgREST |
Real-time Push Sync
Inbound changes from the server are applied locally without re-triggering the outbound queue:
// Zone-bound loop prevention — the secret sauce
await SyncDatabaseDriver.runWithoutQueue(() async {
await dbDriver.insert(table, record); // applied locally only
});
Built-in adapters:
| Adapter | Protocol |
|---|---|
SupabaseRealtimeAdapter |
PostgreSQL logical replication via Supabase Realtime |
WebSocketRealtimeAdapter |
Generic WebSocket push feed |
Custom adapters implement the RealtimeAdapter abstract class:
abstract class RealtimeAdapter {
Stream<RealtimeEvent> get events;
Future<void> connect();
Future<void> disconnect();
}
Media & File Storage
// Fetch file — returns local path instantly if cached, downloads if not
final avatarPath = await storageManager.getFile('avatars', 'user_42.jpg');
// Queue upload — file is copied locally immediately; uploaded when online
await storageManager.queueUpload(
'avatars',
'user_42.jpg',
pickedFile.path,
contentType: 'image/jpeg',
);
// Manually drain the upload queue
await storageManager.triggerUploadQueue();
// Reset backoff timer (e.g. after user retries manually)
storageManager.resetBackoff();
Built-in storage adapters:
| Adapter | Backend |
|---|---|
RestStorageAdapter |
Generic multipart HTTP (POST/PUT) |
SupabaseStorageAdapter |
Supabase Storage buckets |
API Reference
Full API documentation is available at pub.dev/documentation/syncdrift_flutter.
Core Classes
| Class | Description |
|---|---|
DatabaseDriver |
Abstract interface implemented by all driver layers |
DriftDatabaseDriver |
Adapts any GeneratedDatabase to DatabaseDriver |
CachedDatabaseDriver |
SWR cache decorator |
SyncdriftCacheManager |
In-memory TTL cache store |
SyncDatabaseDriver |
Outbound queue interceptor decorator |
SyncQueueProcessor |
Background queue worker |
SyncAdapter |
Abstract sync backend contract |
RestSyncAdapter |
HTTP REST sync adapter |
SupabaseSyncAdapter |
Supabase sync adapter |
RealtimeSyncManager |
Inbound real-time update applier |
RealtimeAdapter |
Abstract realtime source contract |
SupabaseRealtimeAdapter |
Supabase Realtime adapter |
WebSocketRealtimeAdapter |
WebSocket push adapter |
SyncdriftStorageManager |
File cache + offline upload queue |
StorageAdapter |
Abstract storage backend contract |
RestStorageAdapter |
HTTP multipart storage adapter |
SupabaseStorageAdapter |
Supabase Storage adapter |
SyncdriftRepository |
Base class for all generated repositories |
PaginatedResult<T> |
Offset-paginated result with metadata |
TerminalSyncException |
Thrown when a sync operation must not be retried |
Annotations
| Annotation | Target | Description |
|---|---|---|
@Repository() |
Table |
Generate a typed repository class |
@Cached(ttlSeconds: n) |
Table |
Enable SWR caching with TTL |
@SyncTable() |
Table |
Intercept mutations for outbound sync |
@HasMany(T, foreignKey) |
Table |
Declare one-to-many relationship |
@BelongsTo(T, foreignKey) |
Table |
Declare many-to-one relationship |
Configuration
SyncdriftCacheManager options
SyncdriftCacheManager(
cleanupInterval: const Duration(minutes: 5), // default: 5 min
maxEntries: 500, // default: unlimited
)
SyncQueueProcessor options
SyncQueueProcessor(
dbDriver: syncDriver,
syncAdapter: syncAdapter,
maxRetries: 5, // default: 5 — after this → failed_operations
connectivity: Connectivity(),
)
SyncdriftStorageManager options
SyncdriftStorageManager(
dbDriver: rawDriver,
storageAdapter: adapter,
maxRetries: 5,
customCacheDir: '/path/to/cache', // default: getApplicationDocumentsDirectory()
)
Versioning
This project follows Semantic Versioning 2.0.0.
| Version | Status |
|---|---|
0.x.x |
Current — API stabilisation phase. Minor breaking changes possible between 0.x releases. |
1.0.0 |
Planned — stable API guarantee, full migration guide provided |
Pre-1.0 policy: While in
0.x, minor-version bumps (0.x.0) may introduce breaking changes. These are always documented in CHANGELOG.md with aBREAKINGlabel and a migration path. Patch bumps (0.x.y) are backwards-compatible bug fixes only.
See CHANGELOG.md for the full release history.
Contributing
Contributions are welcome! Please read the guidelines before opening a PR.
- Fork the repository on GitHub
- Clone your fork locally
- Install Melos:
dart pub global activate melos - Bootstrap the workspace:
dart run melos bootstrap - Create a branch:
git checkout -b feat/your-feature - Make changes — ensure all tests pass:
dart run melos exec -- flutter test dart run melos exec -- flutter analyze - Open a Pull Request against the
mainbranch
Reporting Issues
Please use GitHub Issues and include:
- SyncDrift version (
syncdrift_flutter: x.y.z) - Flutter version (
flutter --version) - Minimal reproducible example
- Full stack trace if applicable
Security
If you discover a security vulnerability, do not open a public issue. Instead, please email the maintainer directly (see GitHub profile) or use GitHub's private vulnerability reporting.
License
SyncDrift Flutter is distributed under the MIT License.
See LICENSE for the full text.
Made with ❤️ by Farhan Choksi
Libraries
- syncdrift_flutter
- The syncdrift_flutter database framework core package.