dart_data
The SwiftData for Flutter. Zero-boilerplate, offline-first data persistence and sync for Dart.
Apple gave iOS developers SwiftData — a declarative, model-driven persistence layer that Just Works. Flutter deserved the same. dart_data is that answer.
Annotate your models. Get local SQLite persistence, cloud sync, conflict resolution, encryption, and reactive streams — with near-zero configuration.
@SyncModel(tableName: 'todos')
class Todo {
@SyncId()
final String id;
@SyncField(index: true)
final String title;
@SyncField()
final bool completed;
Todo({required this.id, required this.title, this.completed = false});
}
// Initialize once
final dd = await DartData.initialize(
databasePath: 'app.db',
schemas: [TodoSchema()],
);
// CRUD in one line
final repo = dd.repository<Todo>(TodoSchema());
await repo.save(Todo(id: '', title: 'Buy milk'));
// Reactive UI
repo.watchAll().listen((todos) => updateUI(todos));
// Sync when ready
await syncEngine.sync(); // push local, pull remote, resolve conflicts
That's it. No boilerplate. No manual SQL. No sync plumbing.
Why dart_data?
| What you get | SwiftData | dart_data |
|---|---|---|
| Declarative models | @Model |
@SyncModel |
| Auto-persistence | Core Data + SQLite | SQLite with auto-schema |
| Cloud sync | CloudKit | REST, Firebase, Supabase |
| Conflict resolution | Last-write-wins | 6 strategies (LWW, field-merge, 3-way merge, ...) |
| Reactive queries | @Query |
watchAll() / watch() streams |
| Encryption | Data Protection | SQLCipher with key rotation |
| Code generation | Swift macros | build_runner + annotations |
| Platform | Apple only | Everywhere Dart runs |
dart_data brings the SwiftData developer experience to every platform — mobile, web, desktop, and server — with a sync engine that goes far beyond what CloudKit offers.
Features
- Offline-first architecture — Writes go to local SQLite instantly. Sync happens in the background. Your app never blocks on the network.
- Zero-boilerplate models — Annotate with
@SyncModel, runbuild_runner, get typed schemas, repositories, and migrations generated for you. - Full CRUD + reactive streams —
save,findById,findAll,delete,watch,watchAll, batch operations, soft deletes. - Sync engine with operation queue — Persistent queue survives app restarts. FIFO with smart prioritization. Exponential backoff. Dead-letter pool.
- 6 conflict resolution strategies — Last-write-wins, server-wins, client-wins, field-level merge, three-way merge, or fully manual.
- Hybrid Logical Clock — Distributed causal ordering that works even when device clocks disagree. Based on Kulkarni et al. 2014.
- Pluggable backend adapters — REST, Firebase Firestore, and Supabase out of the box. Implement
BackendAdapterfor anything else. - SQLCipher encryption — AES-256 encryption at rest with key rotation support.
- Auto schema migration — Detects column additions, type changes, index changes. Generates diffs. Supports custom migration steps.
- Pure Dart — No Flutter dependency. Works in CLI tools, backend services, and Flutter apps alike.
Installation
dependencies:
dart_data: ^0.1.0
For code generation:
dev_dependencies:
dart_data_generator: ^0.1.0
build_runner: ^2.4.0
Pick your backend:
dependencies:
dart_data_rest: ^0.1.0 # Any REST API
dart_data_firebase: ^0.1.0 # Firebase Firestore
dart_data_supabase: ^0.1.0 # Supabase
Quick Start
1. Define your model
import 'package:dart_data/dart_data.dart';
@SyncModel(tableName: 'todos')
class Todo {
@SyncId()
final String id;
@SyncField(index: true)
final String title;
@SyncField()
final bool completed;
@SyncField(nullable: true, columnName: 'due_date')
final DateTime? dueDate;
Todo({required this.id, required this.title, this.completed = false, this.dueDate});
}
2. Generate the schema
dart run build_runner build
3. Initialize and use
final dd = await DartData.initialize(
databasePath: 'app.db',
schemas: [TodoSchema()],
);
final todos = dd.repository<Todo>(TodoSchema());
// Create
final todo = await todos.save(Todo(id: '', title: 'Ship dart_data'));
// Read
final all = await todos.findAll(
where: QueryBuilder().where('completed', equals: 0),
orderBy: OrderBy('_created_at', descending: true),
limit: 20,
);
// Update
await todos.save(todo.copyWith(completed: true));
// Delete (soft by default, hard optional)
await todos.delete(todo);
await todos.delete(todo, hard: true);
// Reactive streams
todos.watchAll().listen((list) => print('${list.length} todos'));
todos.watch('some-id').listen((todo) => print('Changed: $todo'));
// Batch operations (atomic)
await todos.batch((ctx) async {
await ctx.save(Todo(id: '', title: 'Task 1'));
await ctx.save(Todo(id: '', title: 'Task 2'));
});
Sync Engine
The sync engine provides offline-first synchronization between local storage and any remote backend.
final syncEngine = SyncEngine(
adapter: RestAdapter(),
queue: OperationQueue(storage: sqliteStorage),
statusNotifier: SyncStatusNotifier(),
modelTypes: ['Todo', 'User'],
defaultConflictStrategy: ConflictStrategy.lastWriteWins,
);
// Full sync cycle
final result = await syncEngine.sync();
print('Pushed: ${result.pushSummary.accepted} accepted');
print('Pulled: ${result.pullSummaries.map((p) => p.applied).toList()}');
How it works
LOCAL WRITE
1. Write to SQLite immediately (instant UI update)
2. Enqueue SyncOperation
3. Online? Push now. Offline? Queue persists until connectivity returns.
PUSH (upload)
1. Dequeue pending operations in batch
2. Send to backend → accepted / rejected / conflicted per operation
3. Resolve conflicts with configured strategy
PULL (download)
1. Fetch remote changes since last sync timestamp
2. No local conflict → apply directly
3. Local conflict → resolve with configured strategy
4. Update sync cursor
Conflict Resolution
Six built-in strategies cover every scenario:
| Strategy | Use case |
|---|---|
| Last Write Wins | General purpose. Most recent HLC timestamp wins. |
| Server Wins | Admin-controlled data. Server is always authoritative. |
| Client Wins | Single-user data. Local edits always take precedence. |
| Field-Level Merge | Collaborative editing. Non-conflicting fields merge independently. |
| Three-Way Merge | Precision merging using the common ancestor as base. |
| Manual | Surface conflicts to the user via callback or UI. |
Configure per-model or globally:
final syncEngine = SyncEngine(
adapter: adapter,
queue: queue,
statusNotifier: statusNotifier,
defaultConflictStrategy: ConflictStrategy.lastWriteWins,
modelConflictStrategies: {
'Todo': ConflictStrategy.fieldLevelMerge,
'User': ConflictStrategy.serverWins,
},
);
Encryption
Encrypt your database at rest with SQLCipher:
final dd = await DartData.initialize(
databasePath: 'encrypted.db',
schemas: [TodoSchema()],
encryptionConfig: EncryptionConfig(
key: 'my-secret-key',
kdfIterations: 256000,
pageSize: CipherPageSize.size4096,
),
);
// Key rotation
final storage = dd.storage as SqliteStorage;
await storage.rotateKey(EncryptionConfig(key: 'new-secret-key'));
Schema Migration
Auto-detect and apply schema changes between versions:
final dd = await DartData.initialize(
databasePath: 'app.db',
schemas: [TodoSchemaV2()],
migrationConfig: MigrationConfig(
schemaVersion: 2,
autoMigrate: true,
migrations: [
MigrationStep(
version: 2,
description: 'Add priority column',
up: (db) async {
db.execute('UPDATE todos SET priority = 0 WHERE priority IS NULL');
},
),
],
),
);
Auto-migration handles: adding columns, removing columns, changing column types, changing nullability, adding/removing indexes, and full table rebuilds when needed.
Backend Adapters
REST
import 'package:dart_data_rest/dart_data_rest.dart';
final adapter = RestAdapter();
await adapter.initialize(BackendConfig(
url: 'https://api.example.com',
headers: {'Authorization': 'Bearer token'},
));
Firebase Firestore
import 'package:dart_data_firebase/dart_data_firebase.dart';
final adapter = FirebaseAdapter(projectId: 'my-project');
await adapter.initialize(BackendConfig(
url: 'https://firestore.googleapis.com',
));
Supabase
import 'package:dart_data_supabase/dart_data_supabase.dart';
final adapter = SupabaseAdapter(supabaseUrl: 'https://xyz.supabase.co');
await adapter.initialize(BackendConfig(
headers: {'apikey': 'your-anon-key'},
));
Custom
Implement BackendAdapter to connect to any backend:
class MyAdapter implements BackendAdapter {
@override
Future<void> initialize(BackendConfig config) async { /* ... */ }
@override
Future<List<PushResult>> push(List<SyncOperation> operations) async { /* ... */ }
@override
Future<PullResult> pull(String modelType, {DateTime? since, int? limit}) async { /* ... */ }
// ...
}
Architecture
dart_data (core)
├── Storage Engine ......... SQLite with auto-schema, metadata tracking
├── Repository ............. Type-safe CRUD, queries, reactive streams, batch ops
├── Sync Engine ............ Push/pull orchestration, operation queue, connectivity
├── Conflict Resolution .... 6 strategies, Hybrid Logical Clock ordering
├── Migration Engine ....... Auto-diff, custom steps, table rebuild
└── Encryption ............. SQLCipher AES-256, key rotation
dart_data_generator (codegen)
└── @SyncModel → ModelSchema, toJson/fromJson, typed repositories
dart_data_rest ─┐
dart_data_firebase ├── Backend adapters (implement BackendAdapter)
dart_data_supabase ─┘
Packages
| Package | Description | pub.dev |
|---|---|---|
dart_data |
Core framework | |
dart_data_generator |
Code generation for models | |
dart_data_rest |
REST backend adapter | |
dart_data_firebase |
Firebase Firestore adapter | |
dart_data_supabase |
Supabase adapter |
Contributing
Contributions are welcome! Please file issues and pull requests on GitHub.
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Run tests (
dart testin any package directory) - Submit a pull request
See CONTRIBUTING.md for details.
License
BSD 3-Clause. See LICENSE.
Libraries
- dart_data
- Zero-boilerplate, offline-first data persistence and sync framework for Flutter.