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 · CRUD · Queries · Transactions · Watchers · Web · Testing

Status

Cindel is pre-1.0. The public direction is a generated typed API: application code works with typed collections such as db.users, and each backend adapts internally to that same app code.

Features

  • Generated typed collections from Dart model classes.
  • MDBX as the default native backend.
  • SQLite as an explicit native backend.
  • Experimental SQLite Web/OPFS backend for Flutter Web.
  • Auto-increment ids.
  • Typed CRUD and bulk operations.
  • Generated where() and filter() query helpers.
  • Sorting, pagination, distinct, projections, and aggregates.
  • Read and write transactions.
  • Typed object, collection, query, and lazy watchers.
  • Embedded objects and embedded object lists.
  • Freezed classic class and primary factory support.

Quickstart

1. Add Dependencies

Flutter apps should depend on cindel and cindel_flutter_libs:

dependencies:
  cindel: ^0.6.4
  cindel_flutter_libs: ^0.6.4

dev_dependencies:
  build_runner: ^2.15.0
  cindel_generator: ^0.6.4

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;

  DateTime createdAt = DateTime.now().toUtc();
}

3. Generate Code

dart run build_runner build --delete-conflicting-outputs

The generator creates the schema, serializers, typed collection getter, and query helpers.

4. Open And Use

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

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

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

Freezed

Cindel supports Freezed classic classes and single primary factories:

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 an in-memory database for tests:

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

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

Bulk operations preserve input order and return 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]);

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

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.

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

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: ^0.6.4
  cindel_flutter_libs: ^0.6.4

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 is experimental and should be validated in the target browser.
  • Multi-tab coordination is not part of the current preview.

Native Binaries

Flutter apps should include cindel_flutter_libs so native and Web runtime assets are bundled automatically.

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.

Small Payloads

big=false

Cindel benchmark chart for small payloads

Larger Payloads

big=true

Cindel benchmark chart for larger payloads

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.