Important
π¦ This repository has moved
relax_orm is now developed in the KalybosPro/relax-tech monorepo, together with the rest of the Relax packages.
- π Active source: https://github.com/KalybosPro/relax-tech/tree/main/packages/relax_orm
- π Issues & Pull Requests: please open them in the monorepo β this repo no longer tracks them.
- π₯ On pub.dev: nothing changes.
dart pub add relax_ormkeeps working exactly as before.
This repository is archived for historical reference and will not receive further updates.
RelaxORM
A local-first ORM for Flutter with offline support, real-time streams, automatic sync, and encryption.
Inspired by Firebase and PowerSync β but free, self-hosted, and with no SaaS dependency.
Features
- Simple API β
db.collection<User>()with typed CRUD - Real-time streams β
watchAll()/watchOne()for reactive UI - Offline-first β all operations succeed locally, sync when back online
- Sync engine β push/pull with configurable conflict resolution
- Encryption β transparent AES database encryption via SQLite3MultipleCiphers
- Query builder β fluent, type-safe filters, sorting, pagination
- Code generation β annotate your models, schemas are generated automatically
- Zero SaaS β bring your own API, no vendor lock-in
Quick Start
1. Add dependencies
dependencies:
relax_orm: ^0.1.4
dev_dependencies:
relax_orm_generator: ^0.1.6
build_runner: ^2.4.0
2. Define your model
import 'package:relax_orm/relax_orm.dart';
part 'user.g.dart';
@RelaxTable()
class User {
@PrimaryKey()
final String id;
final String name;
final int age;
final bool active;
final DateTime createdAt;
User({
required this.id,
required this.name,
required this.age,
required this.active,
required this.createdAt,
});
}
3. Generate the schema
dart run build_runner build
This generates user.g.dart containing a userSchema variable with all the column definitions, mappers, and type conversions.
4. Open the database and use it
final db = await RelaxDB.open(
name: 'my_app',
schemas: [userSchema],
encryptionKey: 'optional-secret', // omit for no encryption
);
final users = db.collection<User>();
CRUD Operations
// Create
await users.add(User(id: '1', name: 'Alice', age: 30, active: true, createdAt: DateTime.now()));
// Read
final user = await users.get('1');
final all = await users.getAll();
final count = await users.count();
// Update
await users.update(user.copyWith(name: 'Alice Updated'));
// Upsert (insert or update)
await users.upsert(user);
// Delete
await users.delete('1');
await users.deleteAll();
// Batch insert
await users.addAll([user1, user2, user3]);
Queries
final adults = await users
.query()
.where('age', greaterThan: 18)
.where('active', equals: 1)
.orderBy('name')
.limit(10)
.offset(20)
.find();
// Single result
final admin = await users.query().where('name', equals: 'Admin').findOne();
// Count matching
final activeCount = await users.query().where('active', equals: 1).count();
Available filters
| Filter | Example |
|---|---|
equals |
.where('name', equals: 'Alice') |
notEquals |
.where('status', notEquals: 'banned') |
greaterThan |
.where('age', greaterThan: 18) |
greaterThanOrEquals |
.where('age', greaterThanOrEquals: 18) |
lessThan |
.where('age', lessThan: 65) |
lessThanOrEquals |
.where('score', lessThanOrEquals: 100) |
contains |
.where('name', contains: 'ali') |
startsWith |
.where('name', startsWith: 'Al') |
endsWith |
.where('email', endsWith: '.com') |
isIn |
.where('role', isIn: ['admin', 'mod']) |
isNull |
.where('deletedAt', isNull: true) |
Real-time Streams
// Watch all entities (re-emits on every table change)
users.watchAll().listen((list) {
setState(() => _users = list);
});
// Watch a single entity
users.watchOne('1').listen((user) {
setState(() => _currentUser = user);
});
// Watch a query
users.query().where('active', equals: 1).watch().listen((activeUsers) {
setState(() => _activeUsers = activeUsers);
});
Sync Engine
1. Implement a SyncAdapter for your API
class UserSyncAdapter implements SyncAdapter<User> {
final ApiClient api;
UserSyncAdapter(this.api);
@override
Future<List<User>> push(List<User> entities) async {
final response = await api.post('/users/batch', entities);
return response.users; // server-confirmed versions
}
@override
Future<void> pushDeletes(List<Object> ids) async {
await api.delete('/users/batch', ids);
}
@override
Future<SyncPullResult<User>> pull({DateTime? since}) async {
// `since` is the watermark from the previous pull (null on first sync).
final response = await api.get('/users/changes', since: since);
return SyncPullResult(
upserts: response.updated,
deletedIds: response.deleted,
// Return the server's own timestamp so the next pull resumes exactly
// where this one stopped β immune to client/server clock drift.
serverTime: response.serverTime,
);
}
}
Tip β
serverTime: the engine uses it as thesincevalue for the next pull of that table. When your API can return its authoritative cursor (a server timestamp, a change id, etc.), always set it. If you leave itnull, the engine falls back to the client clock captured before the sync, which is less precise under clock skew but still works.
2. Configure and start
final engine = await db.sync;
engine.register(SyncConfig<User>(
schema: userSchema,
adapter: UserSyncAdapter(api),
conflictResolver: ConflictResolver.remoteWins(), // default
autoSyncInterval: Duration(minutes: 5), // optional
maxRetries: 5, // optional, default 5
));
// Connect your connectivity stream (e.g. from connectivity_plus)
engine.connectivityStream = Connectivity().onConnectivityChanged
.map((result) => result != ConnectivityResult.none);
// Listen to sync status
engine.status.listen((status) {
print(status); // idle, syncing, synced, offline, error
});
// Start syncing
await engine.start();
3. That's it
All CRUD operations on synced collections are automatically queued and pushed when connectivity is restored.
Manual sync
await engine.syncAll(); // sync all registered tables
await engine.syncTable('users'); // sync a specific table
final pending = await engine.pendingCount(); // number of queued operations
Offline queue & coalescing
Every CRUD call on a synced collection is persisted to an internal SQLite queue, so changes survive app restarts and are replayed when connectivity returns. To avoid flooding your API with intermediate states, the queue coalesces repeated edits to the same entity at two levels:
- On write (storage): a new operation is folded into the entity's existing pending row, so the queue holds one row per entity instead of one per edit.
- On push (network): whatever remains pending is folded once more, so the server receives a single write per entity per sync.
Folding rules (in chronological order, per entity):
| Sequence | Result sent to the server |
|---|---|
add β update β β¦ |
a single create with the final state |
update β update β β¦ |
a single update with the final state |
add β delete |
nothing (the entity never reached the server) |
update β delete |
a delete |
delete β add |
an update (re-creation of an existing remote entity) |
So editing a row ten times offline pushes it once, and creating then deleting
a row offline pushes nothing. Operations that have already failed mid-flight
are never silently merged β they keep their own row and are retried on the next
sync (up to maxRetries).
Conflict Resolution
// Remote always wins (default)
ConflictResolver.remoteWins<User>()
// Local always wins
ConflictResolver.localWins<User>()
// Custom logic
ConflictResolver<User>.custom((local, remote) {
return remote.updatedAt.isAfter(local.updatedAt) ? remote : local;
})
Encryption
RelaxORM uses SQLite3MultipleCiphers for transparent database encryption.
Setup
Add to your app's pubspec.yaml:
hooks:
user_defines:
sqlite3:
source: sqlite3mc
Usage
final db = await RelaxDB.open(
name: 'my_app',
schemas: [userSchema],
encryptionKey: 'your-secret-key',
);
The entire database file is encrypted. Without the correct key, the file is unreadable.
Debug Logging
RelaxORM is silent by default. During development you can opt in to a structured logger to observe what the ORM does β database lifecycle, encryption status, CRUD, queries, sync and the offline queue. It is off by default (no runtime cost, no console noise for your users) and only the developer turns it on.
final db = await RelaxDB.open(
name: 'my_app',
schemas: [userSchema],
encryptionKey: 'your-secret-key',
logger: const RelaxLogger(), // enabled; logs to Flutter DevTools "Logging"
);
By default records go to dart:developer's log() (grouped under
relax_orm.<category> in DevTools). You can filter by category, set a minimum
level, or forward records to your own sink:
final db = await RelaxDB.open(
name: 'my_app',
schemas: [userSchema],
logger: RelaxLogger(
categories: {RelaxLogCategory.crud, RelaxLogCategory.encryption},
minLevel: RelaxLogLevel.debug,
sink: (record) => print(record), // your console, a file, a crash reporterβ¦
),
);
Categories: database, encryption, crud, query, sync, queue.
Verifying your data is really encrypted
isEncryptionAvailable() only tells you the cipher is linked. To confirm the
bytes on disk are actually ciphertext, use debugCheckEncryption():
final check = await db.debugCheckEncryption();
print(check.isEncrypted); // true β file is ciphertext, false β plaintext
print(check.isMisconfigured); // true β a key was set but the file is still plaintext
print(check.message); // human-readable explanation (also logged)
It inspects the file header: an unencrypted SQLite file always begins with
SQLite format 3. For databases opened with open() (where drift_flutter
resolves the path), pass the File explicitly: debugCheckEncryption(file: ...).
In-memory databases cannot be inspected and return isEncrypted == null.
Annotations Reference
| Annotation | Usage |
|---|---|
@RelaxTable() |
Marks a class as an ORM entity |
@RelaxTable(name: 'custom') |
Custom table name |
@PrimaryKey() |
Marks the primary key field |
@Column(name: 'col') |
Custom column name |
@Column(nullable: true) |
Nullable column |
@Ignore() |
Excludes a field from the schema |
Supported types
String, int, double, bool, DateTime, Uint8List
Nullable variants (String?, int?, etc.) are also supported.
Database Access
// Production (recommended) β Drift handles paths & isolates
final db = await RelaxDB.open(name: 'app', schemas: [...]);
// Custom file path
final db = await RelaxDB.openFile(file: File('path.db'), schemas: [...]);
// In-memory (testing) β encryption not supported in-memory
final db = await RelaxDB.openInMemory(schemas: [...]);
// Check if the linked SQLite library supports encryption
final supported = await db.isEncryptionAvailable();
// Close when done (also disposes sync engine)
await db.close();
Architecture
+--------------------------------------------------+
| Your Flutter App |
+--------------------------------------------------+
| RelaxDB Collection<T> QueryBuilder |
| (entry point) (typed CRUD) (fluent API) |
+--------------------------------------------------+
| SyncEngine OfflineQueue Conflict |
| (push/pull) (persisted) Resolver |
+--------------------------------------------------+
| Drift (SQLite) SQLite3MultipleCiphers |
| (hidden) (encryption) |
+--------------------------------------------------+
License
MIT
Libraries
- relax_orm
- RelaxORM β A local-first ORM for Flutter.
- relax_orm_annotations
- Annotations-only export for use by code generators.