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 APIdb.collection<User>() with typed CRUD
  • Real-time streamswatchAll() / 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.0

dev_dependencies:
  relax_orm_generator: ^0.1.0
  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 {
    final response = await api.get('/users/changes', since: since);
    return SyncPullResult(
      upserts: response.updated,
      deletedIds: response.deleted,
    );
  }
}

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
));

// 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.

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.

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)
final db = await RelaxDB.openInMemory(schemas: [...]);

// Close when done
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.