relax_orm 1.2.0 copy "relax_orm: ^1.2.0" to clipboard
relax_orm: ^1.2.0 copied to clipboard

A local-first ORM for Flutter with offline support, real-time streams, automatic sync, and encryption.

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
  • Seeding — generated seeders fill your tables with realistic fake data
  • Migrations — a new column needs no migration at all; the rest is declared
  • Zero SaaS — bring your own API, no vendor lock-in

Quick Start #

1. Add dependencies #

dependencies:
  relax_orm: ^1.1.0

dev_dependencies:
  relax_orm_generator: ^1.0.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 relax_orm            # or: dart run build_runner build

This generates user.g.dart containing a userSchema variable with all the column definitions, mappers, and type conversions.

Add --seed to also generate a seeder per model:

dart run relax_orm --seed

See Seeding below.

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 — returns the stored entity, including a generated id when the
// primary key is a null text column (the original object is left untouched).
final stored = 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 — returns the stored entities; active watchAll/watchOne
// streams are notified so the UI refreshes after a bulk import.
final storedAll = 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 ONLY the entities the server accepted. Any entity you omit is
    // treated as not-yet-synced and stays queued for the next sync, so a
    // partial success never silently drops a change. Throw to fail the batch.
    return response.acceptedUsers;
  }

  @override
  Future<List<Object>> pushDeletes(List<Object> ids) async {
    // Return the ids the server confirmed as deleted (same retry contract).
    return 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 the since value 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 it null, 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
addupdate → … a single create with the final state
updateupdate → … a single update with the final state
adddelete nothing (the entity never reached the server)
updatedelete a delete
deleteadd 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;
})

Note — where the resolver runs: conflictResolver is applied only when applying a pull (changes coming from the server). The server-confirmed versions written back after a successful push are treated as authoritative and overwrite the local row without running the resolver — a confirmed push already reflects the state the server accepted.

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.

Migrations #

A database on someone's phone was created by an older build of your app. When a model gains a column, that database doesn't have it — and SQLite won't tell you: CREATE TABLE IF NOT EXISTS compares names, never shapes, so the table is left as it was. Reads keep working (a SELECT * just returns rows without the column), and the first write fails with table … has no column named …, long after the change that caused it.

RelaxORM handles the common half of this on its own.

Adding a column: nothing to do #

Every open compares each schema against its table and appends what's missing:

class Note {
  // ...
  final String? title;   // new
}

Regenerate, ship. The column is added to existing databases on first open, and the rows already there hold NULL.

A NOT NULL column needs a defaultValue — SQLite has to write something into the existing rows:

ColumnDef.integer('reads', defaultValue: '0')

Without one, the open throws and tells you so. It is not skipped: a column silently left out comes back as a failed write much later.

Everything else: declare it #

Renames, type changes and dropped columns can't be inferred — a renamed column and a dropped-then-added one look identical from two schemas, and only you know whether the rows should follow. Raise version and describe the step:

final db = await RelaxDB.open(
  name: 'my_app',
  schemas: [noteSchema],
  version: 2,
  onUpgrade: (m, from, to) async {
    if (from < 2) await m.renameColumn('notes', from: 'body', to: 'text');
  },
);

onUpgrade runs only when the stored version is behind, before the additive pass — so a rename happens while the old column is still there.

Migrator offers addColumn, renameColumn, rebuildTable, and execute / select / columnsOf for the rest. rebuildTable is the way through for what SQLite cannot alter in place; it creates the table anew, copies the rows, drops the old one and renames, in a single transaction:

// Drop a column, and derive a new one from the old rows.
await m.rebuildTable(noteSchema, from: {'length': 'LENGTH(body)'});

from maps a new column name to any SQL expression over the old table. Columns not mentioned are carried over by name.

What the version means #

It is your number, stored in a _relax_schema table. A database created by 1.1.1 or earlier has none and reports from: 0 — unknown, assume the oldest; its missing columns are still added, so an app that only ever appended columns needs no migration code to catch up. Opening a database recorded as newer than the running build throws rather than reading rows with a schema that no longer describes them.

Seeding #

Seeders fill your tables with data — fake data while you build the UI, or fixed reference rows (roles, categories, a default admin) an app needs on first run.

The relax_orm command #

dart run relax_orm is a thin wrapper around build_runner; the --seed flag is what turns seeder generation on.

Command Effect
dart run build_runner build Generates schemas
dart run relax_orm Generates schemas (same thing)
dart run relax_orm --seed Generates schemas and seeders
dart run relax_orm --seed --seed-count=25 …with 25 rows per seeder instead of 10
dart run relax_orm watch Same, in watch mode
dart run relax_orm clean Cleans generated outputs

Anything after -- is forwarded to build_runner verbatim: dart run relax_orm --seed -- --verbose.

Generated seeders #

With --seed, every @RelaxTable model gets a TableSeeder next to its schema — UserUserSeeder — that generates one SeedFaker call per column:

// user.g.dart — generated
class UserSeeder extends TableSeeder<User> {
  @override
  String get tableName => 'users';

  @override
  int get defaultCount => 10;

  @override
  User buildOne(int index, SeedFaker faker) => User(
        id: faker.uuid(),
        name: faker.fullName(),
        age: faker.integer(min: 18, max: 80),
        active: faker.boolean(trueProbability: 0.8),
        createdAt: faker.pastDateTime(),
      );
}

The generator picks the faker call from the column type and its name, so email gets an address, price gets money-shaped numbers and created_at gets a past date. Nested models and List<T> fields are walked recursively; nullable columns are wrapped in faker.maybe(...) so seeded data exercises both branches.

Choosing which models get a seeder #

--seed covers every model. To be explicit instead, annotate the model — it then gets a seeder with or without the flag:

@RelaxTable()
@RelaxSeed(count: 25, order: 1) // order: users before posts
class User { ... }

@RelaxTable()
@RelaxSeed(enabled: false)      // never seeded, even with --seed
class AuditLog { ... }

Running seeders #

final db = await RelaxDB.open(name: 'app', schemas: [userSchema, postSchema]);

db.seeds.registerAll([
  UserSeeder(),           // 10 fake users
  PostSeeder(count: 50),  // 50 fake posts
]);

final report = await db.seeds.run();
print(report); // Seed run — 2 applied, 0 skipped, 0 failed, 60 row(s) in 23ms

run() records every applied seeder in a _relax_seeds ledger table, so calling it again is a no-op — it is safe on every app start. Each seeder runs in its own transaction: a failing seeder leaves no partial rows and no ledger entry, so the next run retries it.

await db.seeds.run(only: ['UserSeeder']);   // subset
await db.seeds.run(force: true);            // ignore the ledger
await db.seeds.run(continueOnError: true);  // don't stop at the first failure

await db.seeds.fresh();                     // wipe the seeded tables, re-seed
await db.seeds.forget(['UserSeeder']);      // let it run again, keep its rows
await db.seeds.appliedNames();              // what has run so far

Failures are reported, not thrown — inspect report.failed, or call report.throwIfFailed().

Generated data is deterministic (the faker is seeded from the seeder's name), and rows are written with an upsert, so re-running a seeder converges on the same rows instead of failing on a duplicate key.

Customizing a generated seeder #

The generated constructor forwards every knob, so you rarely need to subclass:

UserSeeder(count: 100)                       // more rows
UserSeeder(randomSeed: 7)                    // different data, still reproducible
UserSeeder(order: -1)                        // run earlier
UserSeeder(records: [admin, guest])          // fixed rows, no randomness
UserSeeder(builder: (i, faker) =>            // your own generator
    User(id: 'user-$i', name: faker.fullName(), age: 30))

Hand-written seeders #

For anything the generator can't express — cross-table fixtures, custom SQL, data pulled from an asset — extend Seeder directly:

class DefaultRolesSeeder extends Seeder {
  @override
  int get order => -1; // before everything else

  @override
  List<String> get tables => const ['roles']; // so fresh() can clear it

  @override
  Future<void> run(RelaxDB db) async {
    await db.collection<Role>().upsertAll([
      Role(id: 'admin', label: 'Administrator'),
      Role(id: 'member', label: 'Member'),
    ]);
  }
}

SeedFaker #

SeedFaker is available on its own for tests and fixtures:

final faker = SeedFaker(seed: 42); // same seed → same values, always

faker.uuid();        faker.token();       faker.word();
faker.sentence();    faker.paragraph();   faker.slug();
faker.fullName();    faker.email();       faker.username();  faker.phone();
faker.city();        faker.country();     faker.url();       faker.color();
faker.integer(min: 1, max: 10);           faker.decimal(min: 0, max: 5);
faker.boolean(trueProbability: 0.8);      faker.bytes(length: 32);
faker.pastDateTime();                     faker.futureDateTime();
faker.birthDate(minAge: 18, maxAge: 65);
faker.oneOf(['a', 'b']);                  faker.listOf(3, (i) => faker.word());
faker.maybe('value');                     // null sometimes

Pass now: to make date generation reproducible too.

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
@RelaxSeed() Generates a seeder for this model
@RelaxSeed(count: 25, order: 1) Rows to generate, and run order
@RelaxSeed(enabled: false) Never generate a seeder, even with --seed

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     |
+--------------------------------------------------+
|   SeedRunner       Seeder            SeedFaker    |
|   (ledger)         (generated)       (fake data)  |
+--------------------------------------------------+
|   Drift (SQLite)   SQLite3MultipleCiphers         |
|   (hidden)         (encryption)                   |
+--------------------------------------------------+

License #

MIT

2
likes
160
points
239
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A local-first ORM for Flutter with offline support, real-time streams, automatic sync, and encryption.

Repository (GitHub)
View/report issues
Contributing

Topics

#orm #database #offline-first #sync #sqlite

License

MIT (license)

Dependencies

drift, drift_flutter, flutter, path_provider, uuid

More

Packages that depend on relax_orm