Cindel

A Flutter-first local database with generated typed Dart APIs, a Rust native core, MDBX by default, SQLite native, and SQLite Web/OPFS.

Typed collections · Generated queries · Transactions · Watchers · Flutter native and Web

Quickstart · Models · Opening · Migrations · CRUD · Queries · Transactions · Watchers · Sync · Web · Testing

Status

Cindel provides a generated typed API: application code works with typed collections such as db.users. The same models and generated application code work on MDBX, SQLite native, and SQLite Web; each backend adapts internally.

Features

  • Generated typed collections from Dart model classes.
  • MDBX as the default native backend.
  • SQLite as an explicit native backend.
  • SQLite Web/OPFS backend for Flutter Web.
  • Auto-increment ids.
  • Typed CRUD and bulk operations.
  • Typed links and backlinks.
  • Generated where() and filter() query helpers.
  • Sorting, pagination, distinct, projections, and aggregates.
  • Read and write transactions.
  • Typed object, collection, query, and lazy watchers.
  • Atomic migration steps at open time, with versioning, verification, streaming import/export, and optional compaction.
  • Paged document IDs for backup/export tooling on all three backends.
  • Streaming typed backups with links, checksums, atomic restores, native gzip, and portable uncompressed JSONL.
  • Experimental local-first sync configured at open time with an internal durable outbox and backend-neutral adapter contract.
  • Embedded objects and embedded object lists.
  • Freezed classic class and primary factory support.

Quickstart

1. Add Dependencies

Use Dart 3.13 or newer for the current generator and this quickstart. The runtime itself requires Dart 3.11 or newer; cindel_flutter_libs requires Flutter 3.41 or newer.

Flutter apps should depend on cindel and cindel_flutter_libs:

dependencies:
  cindel: ^x.y.z
  cindel_flutter_libs: ^x.y.z

dev_dependencies:
  build_runner: ^2.15.0
  cindel_generator: ^x.y.z

Pure Dart tools can depend on cindel directly and provide a native library with CINDEL_NATIVE_LIBRARY when needed.

2. Define A Model

import 'package:cindel/cindel.dart';

part 'user.g.dart';

@Collection(name: 'users')
class User {
  Id dbId = autoIncrement;

  @Index(unique: true)
  late String email;

  @Index()
  late String name;

  bool active = true;

  @Index()
  DateTime createdAt = DateTime.now().toUtc();

  List<String> tags = const [];
}

3. Generate Code

dart run build_runner build --delete-conflicting-outputs

The generator creates the schema, serializers, typed collection getter, and query helpers. Regenerate model code after upgrading the generator.

4. Open And Use

On native platforms, choose a writable application directory for directory.path. On Web, pass a stable database name instead, as shown below.

final db = await Cindel.open(
  directory: directory.path,
  schemas: [UserSchema],
);

final user = User()
  ..email = 'ada@example.com'
  ..name = 'Ada Lovelace';

await db.users.put(user);

final saved = await db.users.get(user.dbId);
final activeUsers = await db.users.filter().activeEqualTo(true).findAll();

await db.close();

Migrations

Pass a CindelMigrationPlan to Cindel.open on every app start. Cindel stores the database data version inside the database, skips completed steps, and opens the final handle with the target schemas after successful migration.

The conversion below assumes the old model has dbId, email, and fullName fields:

User migrateUser(OldUser old) => User()
  ..dbId = old.dbId
  ..email = old.email
  ..name = old.fullName;

final migrations = CindelMigrationPlan(
  targetVersion: 2,
  baselineVersion: 1,
  steps: [
    CindelMigrationStep(
      fromVersion: 1,
      toVersion: 2,
      openSchemas: [OldUserSchema],
      targetSchemas: [UserSchema],
      verifyBefore: (context) async {
        await context.database.documentIdsPage('users', limit: 1);
      },
      migrate: (context) async {
        await context.registerTargetSchemas();
        await context.importObjectStream(
          UserSchema,
          context.streamObjects(OldUserSchema).map(migrateUser),
        );
      },
      verifyAfter: (context) async {
        await context.database.schemaVersion('users');
      },
    ),
  ],
);

final db = await Cindel.open(
  directory: directory.path,
  schemas: [UserSchema],
  migrationPlan: migrations,
);

Each step is atomic on all three backends: data, schemas, indexes, links, IDs, and the version commit together after verification. A failed step leaves the original intact; earlier successful steps stay committed. Concurrent writes to the original prevent the step from committing.

The streaming helpers read the original snapshot and import in bounded batches, including after registerTargetSchemas. The exportObjects/exportDocuments helpers return full lists. Allow temporary disk space for the migration copy and its transaction. Verification callbacks and compaction are optional.

Maintenance Tooling

Most application code should use generated typed collections and queries. Tooling that needs full database backup can stream a typed archive through CindelBackup. This example uses native file streams (dart:io):

final output = File('backup.cindelbak').openWrite();
await CindelBackup.exportDatabase(
  database: db,
  collections: [
    CindelBackupCollection(UserSchema),
    CindelBackupCollection(OrderSchema),
  ],
  output: output,
);

Restore targets must be opened with matching schemas and must be empty:

await CindelBackup.importDatabase(
  database: restoredDb,
  collections: [
    CindelBackupCollection(UserSchema),
    CindelBackupCollection(OrderSchema),
  ],
  input: File('backup.cindelbak').openRead(),
);

The archive is JSONL after decompression. Native Dart uses gzip by default; Web defaults to uncompressed JSONL. For portable archives, pass compression: CindelBackupCompression.none to both export and import and provide platform-appropriate streams. Schema types are preserved even when the collection list contains different model types.

Backups include documents, links, and migration metadata from one consistent snapshot. Include every collection referenced by a forward link; restore reconstructs backlinks and validates schemas and checksums before committing atomically. Failed imports leave the target unchanged.

Export and import use bounded buffers. Restore also needs temporary disk space (OPFS on Web); database caches and buffers retained by your streams are separate. Records are limited to 16 MiB by default; raise maxRecordBytes on both export and import for larger documents. Legacy version 1 archives contain no links; linked schemas require allowLegacyWithoutLinks: true to accept that loss.

Lower-level tooling can use documentIdsPage to read a bounded, ascending page of ids without materializing the whole collection:

int? afterId;
while (true) {
  final page = await db.documentIdsPage('orders', afterId: afterId, limit: 1000);
  if (page.isEmpty) break;

  final orders = await db.orders.getAll(page);
  // Export, verify, or copy this page.

  afterId = page.last;
}

documentIds() remains available when a full id list is acceptable. Wrap the paged scan in readTxn when all pages must share one consistent snapshot.

Models

Collection Names

Use @Collection for root persisted objects:

@Collection(name: 'accounts')
class Account {
  Id dbId = autoIncrement;

  @Index(unique: true, replace: true)
  late String username;

  String? displayName;
}

Use @Name when the stored collection or field name should differ from the Dart name:

@Name('products')
@collection
class Product {
  Id dbId = autoIncrement;

  @Name('sku_code')
  @Index(unique: true)
  late String sku;
}

Supported Field Shapes

Cindel persists:

  • bool, int, double, and String
  • DateTime and Duration
  • enums
  • nullable supported values
  • embedded objects
  • lists of supported non-list values
  • lists of embedded objects
  • nullable lists and nullable list elements

For supported element types T, List<T>, List<T>?, List<T?>, and List<T?>? use the same generated API on all three backends. Null lists, empty lists, and lists containing nulls remain distinct values through individual and bulk writes, reads, and property projections.

DateTime is stored as epoch microseconds and read in UTC; Duration uses microseconds. For data shared with Web, keep integers and integer-backed values within the JavaScript safe range of ±9007199254740991. Use finite doubles.

Ignore transient fields with @ignore:

@ignore
String runtimeOnlyLabel = '';

Enums

Enums can be stored by name, ordinal, or a value field:

enum Plan {
  free('free'),
  pro('pro');

  const Plan(this.code);

  final String code;
}

@collection
class Subscription {
  Id dbId = autoIncrement;

  @Enumerated(CindelEnumType.value, valueField: 'code')
  Plan plan = Plan.free;
}

Enum lists use name-based storage; @Enumerated applies to scalar enum fields.

Freezed

Cindel supports Freezed classic classes and single primary factories:

Add freezed_annotation as a dependency and freezed as a dev dependency alongside the Cindel generator.

import 'package:cindel/cindel.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'product.freezed.dart';
part 'product.g.dart';

@freezed
@Collection(name: 'products')
abstract class Product with _$Product {
  const factory Product({
    required Id dbId,
    @Index(unique: true) required String sku,
    @Index() required String name,
    @Default(true) bool active,
  }) = _Product;
}

Freezed union/sealed multi-constructor models are not supported.

Opening A Database

MDBX is the default backend on native platforms:

final db = await Cindel.open(
  directory: directory.path,
  schemas: [UserSchema, AccountSchema],
);

Select SQLite explicitly when you want the native SQLite backend:

final db = await Cindel.open(
  directory: directory.path,
  schemas: [UserSchema],
  backend: CindelStorageBackend.sqlite,
);

Use openInMemory for tests:

final db = await Cindel.openInMemory(schemas: [UserSchema]);

Storage depends on the backend: SQLite native uses RAM, MDBX uses a temporary directory cleaned up on close, and Web uses SQLite/OPFS under a unique database name. The same test API does not imply RAM-only storage on every platform.

CRUD

Generated collections are available from the database handle:

final ada = User()
  ..email = 'ada@example.com'
  ..name = 'Ada Lovelace';

await db.users.put(ada);

final saved = await db.users.get(ada.dbId);

await db.users.delete(ada.dbId);

Use putAll and deleteAll for bulk writes and deletes. getAll preserves input order and returns null for missing ids:

await db.users.putAll([ada, grace, linus]);

final users = await db.users.getAll([ada.dbId, 404, grace.dbId]);

await db.users.deleteAll([ada.dbId, grace.dbId]);

putAll assigns automatic IDs in input order and preserves explicit IDs. Duplicate IDs in a batch are rejected, and IDs are not guaranteed to be gapless. Split very large inputs into batches; putAll materializes its input.

Unique indexes with replace: true generate natural-key upsert helpers:

@collection
class Account {
  Id dbId = autoIncrement;

  @Index(unique: true, replace: true)
  late String username;
}
final account = Account()..username = 'ada';

await db.accounts.putByUsername(account);

If another row already has username == 'ada', the generated helper reuses that id instead of inserting a duplicate.

Queries

Use generated where() helpers for indexed lookups:

final ada = await db.users
    .where()
    .emailEqualTo('ada@example.com')
    .findFirst();

final recentUsers = await db.users
    .where()
    .createdAtBetween(start, end)
    .findAll();

Use generated filter() helpers for general filtering:

final activeUsers = await db.users
    .filter()
    .activeEqualTo(true)
    .sortByName()
    .findAll();

String fields support contains, prefix, and suffix filters:

final matches = await db.users
    .filter()
    .nameContains('Ada')
    .findAll();

List fields expose element and length helpers:

final flutterUsers = await db.users
    .filter()
    .tagsElementEqualTo('flutter')
    .findAll();

final taggedUsers = await db.users
    .filter()
    .tagsLengthGreaterThan(0)
    .findAll();

Compose dynamic filters with optional, anyOf, and allOf:

final bySearch = await db.users
    .filter()
    .optional(search.isNotEmpty, (q) => q.nameContains(search))
    .findAll();

final byTags = await db.users
    .filter()
    .anyOf(selectedTags, (q, tag) => q.tagsElementEqualTo(tag))
    .findAll();

Queries can count, delete, update, sort, paginate, deduplicate, project, and aggregate:

final count = await db.users.filter().activeEqualTo(true).count();

final names = await db.users
    .filter()
    .activeEqualTo(true)
    .sortByName()
    .offset(20)
    .limit(10)
    .nameProperty()
    .findAll();

final maxId = await db.users.all().dbIdProperty().max();

final updated = await db.users
    .filter()
    .activeEqualTo(false)
    .updateAll({'active': true});

final deleted = await db.users
    .filter()
    .activeEqualTo(false)
    .deleteAll();

findFirst() returns at most one result, including projections, and respects offset and distinct. count() counts the filtered, distinct, paginated window; property count() counts non-null values. Distinct uses structured value equality. MDBX preserves 64-bit integer precision in filters and ordering. Indexes and ordering affect query cost; a limit bounds results, not necessarily the number of candidates scanned.

Indexes

Add @Index to fields that should be queryable through generated where() helpers or optimized by the backend:

@collection
class Article {
  Id dbId = autoIncrement;

  @Index()
  late String title;

  @Index(caseSensitive: false)
  late String normalizedTitle;

  @Index(type: CindelIndexType.words, caseSensitive: false)
  late String body;

  @Index(type: CindelIndexType.multiEntry, caseSensitive: false)
  List<String> tags = const [];
}

Composite indexes are declared on the collection:

@Collection(
  name: 'events',
  indexes: [
    CompositeIndex(['accountId', 'createdAt']),
  ],
)
class Event {
  Id dbId = autoIncrement;

  @Index()
  late int accountId;

  @Index()
  late DateTime createdAt;
}

Transactions

Use writeTxn when several writes must commit or roll back together:

await db.writeTxn(() async {
  await db.users.put(ada);
  await db.accounts.put(account);
});

Use readTxn for a consistent read block:

final users = await db.readTxn(() {
  return db.users.filter().activeEqualTo(true).findAll();
});

If a write transaction throws, Cindel rolls back the pending writes and ID reservations made inside that transaction. Dart object mutations, including assigned IDs, are not rolled back. Writes and ID allocation are rejected inside readTxn.

Storage failures preserve diagnostic context through CindelError.operation, backend, code, and cause when available. A failed read is an error, not an empty result or successful partial result.

Use CindelLink<T> for to-one relations, CindelLinks<T> for to-many relations, and @Backlink(to: ...) for read-only inverse relations:

@Collection(name: 'artists')
class Artist {
  Id dbId = autoIncrement;
  late String name;

  @Backlink(to: 'featuredArtists')
  final songs = CindelLinks<Song>();
}

@Collection(name: 'songs')
class Song {
  Id dbId = autoIncrement;
  late String title;

  final featuredArtists = CindelLinks<Artist>();
  final primaryArtist = CindelLink<Artist>();
}

Persist linked objects first, then save the relation explicitly:

await db.writeTxn(() async {
  await db.artists.put(artist);
  await db.songs.put(song);

  song.featuredArtists.add(artist);
  song.primaryArtist.value = artist;
  await song.featuredArtists.save();
  await song.primaryArtist.save();
});

final storedSong = await db.songs.get(song.dbId);
await storedSong!.featuredArtists.load();
await storedSong.primaryArtist.load();

Forward link saves replace the persisted ids for that link. Backlinks load from the forward relation and are read-only. In @Backlink(to: ...), pass the Dart field name of the forward link on the linked collection; in the example above, Artist.songs points to Song.featuredArtists.

Deleting a document, including batch/query deletes and unique-index evictions, removes its incoming and outgoing links atomically and notifies related collections after commit. Updating an existing ID preserves its links; deleting and reinserting it does not restore them. Migration reconstruction preserves links for rewritten documents.

For older databases, await db.cleanupOrphanLinks() removes links whose source or target is missing. Run it outside a transaction; cleanup is atomic and paged, and returns examinedLinks and removedLinks. Links to an already reused ID cannot be identified as historical orphans.

Watchers

Watch one object:

final sub = db.users.watchObject(ada.dbId).listen((user) {
  // user is null when the object does not exist.
});

Watch a collection:

final sub = db.users.watchCollection().listen((users) {
  // Full typed snapshot.
});

Watch a query:

final sub = db.users
    .filter()
    .activeEqualTo(true)
    .sortByName()
    .watch()
    .listen((users) {
      // Matching typed snapshot.
    });

Lazy watchers emit invalidation signals without returning objects:

final sub = db.users.watchCollectionLazy().listen((_) {
  // Refresh cached state.
});

Watchers share polling per collection and react to committed changes; rolled-back writes do not publish changes. Object, collection, and query watchers emit an initial snapshot by default; lazy watchers do not. Use fireImmediately to change this behavior.

Handle stream errors with onError and cancel subscriptions when their owner is disposed. Closing the database stops polling and completes watcher streams.

await sub.cancel();
await db.close();

Sync

Sync is experimental and configured only when opening the database. App code keeps using the same typed collections, queries, transactions, and watchers; Cindel records local mutations in an internal durable outbox and runs an internal scheduler against your adapter.

Warning

This is an early integration API, not a complete production conflict-resolution layer. Before using it for critical data, validate your backend's idempotency, checkpoint, rejection, reset, and conflict policies with your own app flows. The sync API may change before it is marked stable.

final db = await Cindel.open(
  directory: dir.path,
  schemas: [UserSchema],
  sync: CindelSyncConfig(
    adapter: AppSyncAdapter(),
    onStatusChanged: (status) {
      // Show offline/syncing/pending state in your UI.
    },
    onError: (error, stackTrace) {
      // Report background sync failures.
    },
  ),
);

The adapter supplies backend-specific push/pull behavior:

final class AppSyncAdapter implements CindelSyncAdapter {
  @override
  Future<CindelPushResult> push(CindelPushRequest request) async {
    // Send request.mutations to your backend idempotently.
    return CindelPushResult(acceptedMutationIds: {
      for (final mutation in request.mutations) mutation.mutationId,
    });
  }

  @override
  Future<CindelPullResult> pull(CindelPullRequest request) async {
    // Return backend changes after request.checkpoint.
    return const CindelPullResult(checkpoint: '0', changes: []);
  }
}

Supported initial sync mutations are typed document upserts, deletes, query deletes, and link replacements. Query updates are rejected while sync is enabled because they do not yet produce canonical per-document mutations.

Embedded Objects

Use @embedded for values stored inside a parent collection object:

@collection
class Email {
  Id dbId = autoIncrement;

  String? subject;

  Recipient? sender;

  List<Recipient>? recipients;
}

@embedded
class Recipient {
  String? name;
  String? address;
  RecipientMetadata? metadata;
}

@embedded
class RecipientMetadata {
  String? label;
}

Generated filters can query inside embedded objects:

final messages = await db.emails
    .filter()
    .sender((recipient) => recipient.addressEqualTo('ada@example.com'))
    .findAll();

final secondary = await db.emails
    .filter()
    .recipientsElement((recipient) {
      return recipient.metadata((metadata) {
        return metadata.labelEqualTo('secondary');
      });
    })
    .findAll();

Embedded classes are not root collections. Add indexes to root collection fields, not inside embedded classes.

Web

Flutter Web uses the same typed app code:

final db = await Cindel.open(
  directory: 'app.db',
  schemas: [UserSchema],
);

Keep both packages in the app:

dependencies:
  cindel: ^x.y.z
  cindel_flutter_libs: ^x.y.z

Current Web behavior:

  • Web uses SQLite in a Worker with OPFS persistence.
  • Cindel.open(...) loads the packaged Worker and Wasm assets.
  • MDBX is not used in the browser.
  • Generated typed CRUD, queries, transactions, and single-tab watchers are the supported Web path.
  • Web support should be validated in the target browser.
  • Multi-tab coordination is not supported.

Native Binaries

Flutter apps should include cindel_flutter_libs so native and Web runtime assets are bundled automatically; no Dart import from that package is needed. Keep it compatible with cindel, since native libraries must match the ABI expected by the Dart runtime.

iOS and macOS support both Swift Package Manager and CocoaPods. Flutter handles the integration with the same public Cindel API and no manual linker settings.

For custom local native builds, set CINDEL_NATIVE_LIBRARY before running Dart tests or tools:

$env:CINDEL_NATIVE_LIBRARY = 'D:\path\to\cindel_native.dll'

Testing

Use openInMemory for fast unit tests:

test('stores users', () async {
  final db = await Cindel.openInMemory(schemas: [UserSchema]);
  addTearDown(db.close);

  final user = User()
    ..email = 'ada@example.com'
    ..name = 'Ada';

  await db.users.put(user);

  expect(await db.users.get(user.dbId), isNotNull);
});

Benchmarks

Benchmarks are a rough signal rather than a performance guarantee. They are useful for tracking whether changes move Cindel in the right direction.

Cindel and Isar benchmark results

License

Cindel is licensed under the Apache License, Version 2.0. See the repository license file for details.

Libraries

cindel
Public Dart API for opening Cindel databases, defining schemas, and running generated typed collections and queries.