Datum Banner

๐Ÿง  Datum โ€” Offline-First Data Synchronization for Dart & Flutter

Pub License Code Coverage Tests Version

Your backend, your database โ€” one type-safe sync engine.

Datum owns the hard part of local-first apps: reconciling a device's database with a remote backend โ€” conflicts, retries, queues, migrations and all โ€” behind a single API. Local writes are instant; sync happens when connectivity allows; every device converges.

๐Ÿ“š Full documentation โ†’ datum.shreeman.dev

Every code snippet in this README is compiled against the real APIs by the documentation snippet checker โ€” what you copy is what runs.


Why Datum

  • ๐Ÿ”Œ Offline-first core โ€” instant local writes, an automatic pending-operation queue, replay on reconnect, reactive watch* streams, and per-user data isolation.
  • โšก Incremental sync at scale โ€” timestamp deltas or opaque change-feed cursors pull only what changed; content-hash skip checks make idle cycles cost almost nothing (O(1) in requests, regardless of dataset size).
  • ๐Ÿค Conflict resolution that converges โ€” version + timestamp last-write-wins with deterministic tie-breaking, vector clocks for true causality, custom resolvers, and real CRDTs (counters, sets, ordered lists, collaborative text) when concurrent edits must all survive.
  • ๐Ÿ—‚๏ธ Schema migrations โ€” declarative column operations with fail-fast chain validation, snapshot rollback, and run-once stamping. The same chain runs as raw-map rewrites on Hive and as real ALTER TABLE DDL on SQLite.
  • ๐Ÿงช A conformance kit, not just tests โ€” certify your adapter or whole stack with one call from datum_test: network chaos profiles, crash-recovery, seeded convergence fuzzing.
  • ๐Ÿ› ๏ธ Type-safe by construction โ€” typed errors with tryX result APIs, generated entity boilerplate, type-safe query fields, and adapter capability mixins instead of runtime probing.

Pure client library. No hosted service, no lock-in โ€” Hive, SQLite, or in-memory locally; Supabase, Firebase, or any REST API remotely. MIT licensed.


๐Ÿš€ Quick start

1. Install

dependencies:
  datum: ^1.1.0

2. Define an entity

Extend DatumEntity โ€” id, userId, and the sync metadata fields are what the engine reconciles on. (Or let code generation write the boilerplate for you.)

import 'package:datum/datum.dart';
import 'package:datum_test/datum_test.dart'; // HttpRemoteAdapter (reference adapter)

class Task extends DatumEntity {
  const Task({
    required this.id,
    required this.userId,
    required this.title,
    this.done = false,
    required this.createdAt,
    required this.modifiedAt,
    required this.version,
    this.isDeleted = false,
  });

  factory Task.fromMap(Map<String, dynamic> map) => Task(
        id: map['id'] as String,
        userId: map['userId'] as String,
        title: map['title'] as String? ?? '',
        done: map['done'] as bool? ?? false,
        createdAt: DateTime.parse(map['createdAt'] as String),
        modifiedAt: DateTime.parse(map['modifiedAt'] as String),
        version: (map['version'] as num?)?.toInt() ?? 1,
        isDeleted: map['isDeleted'] as bool? ?? false,
      );

  @override
  final String id;
  @override
  final String userId;
  final String title;
  final bool done;
  @override
  final DateTime createdAt;
  @override
  final DateTime modifiedAt;
  @override
  final int version;
  @override
  final bool isDeleted;

  @override
  Map<String, dynamic> toDatumMap({MapTarget target = MapTarget.local}) => {
        'id': id,
        'userId': userId,
        'title': title,
        'done': done,
        'createdAt': createdAt.toIso8601String(),
        'modifiedAt': modifiedAt.toIso8601String(),
        'version': version,
        'isDeleted': isDeleted,
      };

  @override
  Map<String, dynamic>? diff(covariant DatumEntityInterface oldVersion) =>
      toDatumMap(target: MapTarget.remote);

  /// Every edit bumps [version] and [modifiedAt] โ€” that's what sync compares.
  Task copyWith({String? title, bool? done}) => Task(
        id: id,
        userId: userId,
        title: title ?? this.title,
        done: done ?? this.done,
        createdAt: createdAt,
        modifiedAt: DateTime.now(),
        version: version + 1,
        isDeleted: isDeleted,
      );

  @override
  List<Object?> get props => [...super.props, title, done];
}

/// Wire in connectivity_plus or your own checker in a real app.
class AlwaysOnline implements DatumConnectivityChecker {
  const AlwaysOnline();
  @override
  Future<bool> get isConnected async => true;
  @override
  Stream<bool> get onStatusChange => const Stream.empty();
}

3. Initialize once, then it's three calls

Future<void> main() async {
  await Datum.initialize(
    config: const DatumConfig(enableLogging: true),
    connectivityChecker: const AlwaysOnline(),
    registrations: [
      DatumRegistration<Task>(
        localAdapter: InMemoryLocalAdapter<Task>(fromMap: Task.fromMap),
        // The reference HTTP adapter from datum_test โ€” swap in the Supabase,
        // Firebase, or REST adapter for your backend (guides below).
        remoteAdapter: HttpRemoteAdapter<Task>(
          baseUri: Uri.parse('https://api.example.com'),
          fromMap: Task.fromMap,
        ),
      ),
    ],
  );

  final tasks = Datum.manager<Task>();

  // 1. Instant local write โ€” queued for sync automatically.
  await tasks.push(
    item: Task(
      id: 't1',
      userId: 'u1',
      title: 'Ship it',
      createdAt: DateTime.now(),
      modifiedAt: DateTime.now(),
      version: 1,
    ),
    userId: 'u1',
  );

  // 2. React to data changes anywhere in the app.
  tasks.watchAll(userId: 'u1').listen((all) => print('${all.length} tasks'));

  // 3. Reconcile with the backend whenever you choose (or let auto-sync run).
  final result = await tasks.synchronize('u1');
  print('synced: ${result.syncedCount}, failed: ${result.failedCount}');
}

Swap the adapters for Hive, SQLite, Supabase, Firebase, or your own backend โ€” the rest of your code does not change.


๐Ÿ“ Everyday operations

CRUD, typed queries, and reactive streams all hang off the manager:

final tasks = Datum.manager<Task>();

// Read one / all (local-first).
final one = await tasks.read('t1', userId: 'u1');
final all = await tasks.readAll(userId: 'u1');

// Update = push a bumped copy; delete is a soft delete that syncs.
if (one != null) {
  await tasks.push(item: one.copyWith(done: true), userId: 'u1');
}
await tasks.delete(id: 't1', userId: 'u1');

// Typed queries: filter + sort + paginate. SQL-backed adapters push this
// down into the database instead of filtering in memory.
final urgent = await tasks.query(
  const DatumQuery(
    filters: [Filter('done', FilterOperator.equals, false)],
    sorting: [SortDescriptor('createdAt', descending: true)],
    limit: 20,
  ),
  source: DataSource.local,
  userId: 'u1',
);
print('${all.length} total, ${urgent.length} open');

// Reactive variants keep UI live: watchAll / watchById / watchQuery.
tasks.watchQuery(
  const DatumQuery(filters: [Filter('done', FilterOperator.equals, false)]),
  userId: 'u1',
).listen((open) => print('${open.length} open tasks'));

// Choose freshness per call when reading through to the backend.
final freshest = await tasks.fetchById(
  't2',
  strategy: DataFetchStrategy.remoteFirst,
  userId: 'u1',
);
print(freshest?.title);

Offline is not an error state: writes made without connectivity queue as pending operations and replay on the next sync โ€” verified end to end by the conformance suites.


๐Ÿค Conflict resolution

When the same entity changed on two devices, the engine detects it (version + timestamp + content comparison, or vector clocks for true causality) and asks a resolver. The default is last-write-wins with a deterministic tie-break, so every device converges to the same answer:

final config = DatumConfig<Task>(
  defaultConflictResolver: LastWriteWinsResolver<Task>(),
);
print(config.schemaVersion);

Plug in your own DatumConflictResolver for field-level merges โ€” see Advanced Sync.

CRDTs, when every edit must survive

For counters, sets, lists, and collaborative text, Datum ships real CRDTs โ€” merge in any order, converge everywhere:

var likesOnPhone = const PNCounter();
var likesOnLaptop = const PNCounter();

likesOnPhone = likesOnPhone.increment('phone');
likesOnPhone = likesOnPhone.increment('phone');
likesOnLaptop = likesOnLaptop.increment('laptop');

// Merge in any order โ€” both devices converge on 3.
print(likesOnPhone.merge(likesOnLaptop).value); // 3
var note = RgaText(replicaId: 'phone');
note = note.insert(0, 'Hello world');

// A second device loads the same documentโ€ฆ
var laptop = RgaText.fromMap(note.toMap(), replicaId: 'laptop');

// โ€ฆand both edit concurrently.
note = note.insert(5, ',');
laptop = laptop.insert(laptop.length, '!');

note = note.merge(laptop);
laptop = laptop.merge(note);
print(note.value == laptop.value); // true โ€” "Hello, world!"

More in Collaborative Editing & CRDTs.


๐Ÿ—‚๏ธ Schema migrations

Your app ships v2 with a renamed field and a new column; devices still hold v1 data. Declare the chain once โ€” it runs in place, on startup, exactly once, with fail-fast validation and rollback:

final config = DatumConfig<Task>(
  schemaVersion: 1,
  migrations: [
    SchemaMigration(fromVersion: 0, toVersion: 1, operations: [
      ColumnOperation.rename('name', to: 'title'),
      ColumnOperation.add('priority', defaultValue: 0),
    ]),
  ],
);
print(config.schemaVersion);

On SQL stores the same chain executes as real DDL (ALTER TABLE / UPDATE) inside one transaction:

final result = await SqlMigrationExecutor<Task>(
  localAdapter: localAdapter, // any adapter mixing in RawQueryCapable
  table: 'tasks',
  migrations: [
    SchemaMigration(fromVersion: 0, toVersion: 1, operations: [
      ColumnOperation.add('priority', defaultValue: 0),
      ColumnOperation.transform(
        'title',
        (value, row) => (value as String? ?? '').trim(),
        sqlExpression: 'TRIM(title)',
      ),
    ]),
  ],
  targetVersion: 1,
  dialect: SqlDialect.sqlite,
  logger: DatumLogger(),
).execute();
if (!result.success) {
  print('Nothing was modified: ${result.migrationError}');
}

Full guide: Schema Migrations.


๐Ÿงฌ Typed schemas & auto-migration โ€” no codegen

Declare each field once as a DatumFieldSpec and that declaration powers typed queries, cast-free map reads, derived SQLite columns, and automatic schema reconciliation:

abstract final class TaskFields {
  static final title = DatumFieldSpec<Task, String>('title',
      getter: (t) => t.title, defaultValue: '');
  static final done = DatumFieldSpec<Task, bool>('done',
      getter: (t) => t.done, defaultValue: false, renamedFrom: 'completed');
}

final core = datumCoreFieldSpecs<Task>();
final taskSchema = DatumSchema<Task>(
  name: 'tasks',
  fields: [...core.all, TaskFields.title, TaskFields.done],
);

// Typed queries โ€” a spec IS-A DatumQueryField, typos fail at compile time:
final open = DatumQueryBuilder<Task>()
    .whereField(TaskFields.done, isEqualTo: false)
    .orderByField(TaskFields.title)
    .build();

// Cast-free reads with field-named errors (no `as` in fromMap):
Task readTask(Map<String, dynamic> map) {
  final r = taskSchema.reader(map);
  return Task(
    id: r(core.id),
    userId: r(core.userId),
    title: r(TaskFields.title),
    done: r.getOr(TaskFields.done, false),
    createdAt: r(core.createdAt),
    modifiedAt: r(core.modifiedAt),
    version: r(core.version),
  );
}

Then let initialize() keep the store in shape โ€” added fields are backfilled with their defaults, renames are honored via the renamedFrom: hint (real ALTER TABLE on SQLite, raw-map rewrites on Hive), and a stored fingerprint makes unchanged launches skip the whole pass:

final config = DatumConfig<Task>(schema: taskSchema, autoMigrate: true);
print(config.autoMigrate);

Manual SchemaMigration chains keep working unchanged โ€” the auto pass runs after them and never touches the stored schema version. Full guide: Typed Schemas & Auto-Migration.


๐Ÿ—„๏ธ Storage & backends

Package What it is
datum The engine + in-memory adapter (this package)
datum_sqlite SQLite local adapter โ€” real tables, SQL query pushdown, transactions, DDL migrations
datum_hive Hive CE local adapter for Flutter
datum_test Conformance kit + local sync server + reference HTTP adapters
datum_generator Code generation for entity boilerplate
import 'package:datum_sqlite/datum_sqlite.dart';
import 'package:sqlite3/sqlite3.dart';

final db = sqlite3.open('app.db');
final sqliteAdapter = SqliteLocalAdapter<Task>(
  database: db,
  table: 'tasks',
  fromMap: Task.fromMap,
  columns: {'title': 'TEXT', 'done': 'BOOLEAN'},
);
print(sqliteAdapter.table);
import 'package:datum_hive/datum_hive.dart';

final hiveAdapter = HiveLocalAdapter<Task>(
  entityBoxName: 'tasks',
  fromMap: Task.fromMap,
);

Building your own is two small interfaces: local adapter guide ยท remote adapter guide.


๐Ÿงช Testing your stack

Certify any adapter (or your entire sync stack) with one call from datum_test โ€” then go further with network chaos profiles, crash-recovery with exactly-once delivery, and seeded multi-device convergence fuzzing:

runLocalAdapterConformanceTests(
  name: 'InMemory',
  create: () async {
    final adapter = InMemoryLocalAdapter<ConformanceEntity>(
      fromMap: ConformanceEntity.fromMap,
    );
    await adapter.initialize();
    return adapter;
  },
);

Full guide: Testing Your Sync Stack.


โšก Performance

Measured, not promised โ€” by the micro-benchmark suite (benchmark/ on the Dart VM) and an end-to-end integration suite that runs both SQLite and Hive against a real HTTP sync server on an iOS simulator (debug build):

What Result
Idle sync cycle ~1โ€“2 ms, O(1) requests โ€” flat at any dataset size
Cursor delta pull (10 changed of 500) only the change feed is transferred, never the full table
SQL migration, 3 steps ร— 1,000 rows ~5 ยตs/row (set-based DDL) vs ~24 ยตs/row map path
Collaborative-text keystroke ~26 ยตs (VM micro-benchmark)
Incremental dataset-hash update ~17 ยตs โ€” full rescans eliminated

Tuning knobs, delta sync, cursors, and hash caching: Incremental Sync ยท Performance Tuning.


โš–๏ธ How Datum compares

Approach Examples Where Datum differs
Hosted sync service PowerSync, Ditto, Realm/Atlas Device Sync Datum is a pure client library โ€” no service to run or pay for; your backend stays exactly as it is
Backend-bundled offline cache Firebase/Firestore offline persistence Datum is backend-agnostic โ€” the same app code syncs against Supabase, Firebase, or any REST API via adapters
Roll your own timestamps + ConnectivityPlus + hope Datum is that engine, already built โ€” 100% test line coverage, wire-level integration suites, fuzz-verified convergence

The honest flip side: a hosted service can give you server-enforced partial replication and dashboards out of the box; a backend-bundled cache is nearly zero-setup if you're all-in on that backend. Datum's bet is control without lock-in โ€” you bring the backend, it brings the engine.


๐Ÿ“š Explore the docs


๐Ÿ”ฎ Future plans

  • Multi-adapter fan-out โ€” register multiple remotes (or locals) per entity, e.g. sync to a REST API and Firebase simultaneously.
  • More adapters (Drift, PostgreSQL, GraphQL), CLI tooling, and a first-class web story โ€” see Coming Soon.

โค๏ธ Support & Contributions

Support This Project

If you find this package helpful and would like to support its development, please consider buying me a coffee. Your support is greatly appreciated and helps me dedicate more time to improving and maintaining this project.

Buy Me A Coffee

Contributing

Contributions are welcome! If you have a feature request, bug report, or want to contribute to the code, please see our Contributing Guidelines. Let's make Datum even better together!


๐Ÿ™ Acknowledgements

This project is heavily inspired by the great work of the synq_manager package and its author Ahmet Aydin. A big thank you for the inspiration and the solid foundation provided to the Flutter community.


๐Ÿชช License

MIT License

Copyright (c) 2025 Shreeman Arjun Sahu

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Libraries

datum
source/adapter/adapter_capabilities
Opt-in capability markers for adapters.
source/adapter/in_memory_local_adapter
source/adapter/local_adapter
source/adapter/remote_adapter
source/config/config_presets
source/config/datum_config
source/core/cascade_delete
source/core/engine/_internal
source/core/engine/_isolate_helper_io
source/core/engine/_isolate_helper_unsupported
source/core/engine/_isolate_helper_web
source/core/engine/conflict_detector
source/core/engine/datum_core
source/core/engine/datum_observer
source/core/engine/datum_sync_engine
source/core/engine/error_boundary
source/core/engine/isolate_helper
source/core/engine/metadata_hash_cache
source/core/engine/queue_manager
source/core/engine/sync_error_handler
source/core/errors/datum_error
source/core/errors/datum_exception
source/core/events/conflict_detected_event
source/core/events/conflict_resolved_event
source/core/events/data_change_event
source/core/events/datum_event
source/core/events/datum_sync_statistics
source/core/events/initial_sync_event
source/core/events/user_switched_event
source/core/health/datum_health
source/core/manager/cascade_delete_coordinator
source/core/manager/cold_start_manager
source/core/manager/datum_manager
source/core/manager/datum_sync_request_strategy
source/core/manager/disposable
source/core/manager/manager_cache_coordinator
source/core/manager/relation_loader
source/core/middleware/datum_middleware
source/core/migration/auto/auto_migration_executor
Reconciles a store's actual shape with its declared DatumSchema โ€” the executable half of auto-migration.
source/core/migration/auto/schema_diff
The pure, synchronous heart of auto-migration: compare a declared DatumSchema against a store's observed SchemaShape and produce the ColumnOperations that reconcile them.
source/core/migration/auto/schema_introspector
Reads the actual stored shape of an entity's data so the auto-migration differ can compare it against the declared DatumSchema.
source/core/migration/migration
source/core/migration/migration_executor
source/core/migration/migration_plan
source/core/migration/schema_migration
source/core/migration/sql_schema_migration
source/core/models/cold_start_strategy
source/core/models/conflict_context
source/core/models/crdt
source/core/models/data_fetch_strategy
source/core/models/data_source
source/core/models/datum_change_detail
source/core/models/datum_either
source/core/models/datum_entity
source/core/models/datum_index_config
source/core/models/datum_metrics
source/core/models/datum_operation
source/core/models/datum_pagination
source/core/models/datum_registration
source/core/models/datum_sync_conflict_summary
source/core/models/datum_sync_metadata
source/core/models/datum_sync_operation
source/core/models/datum_sync_options
source/core/models/datum_sync_result
source/core/models/datum_sync_scope
source/core/models/datum_sync_status_snapshot
source/core/models/error_strategy
source/core/models/excludable_entity
source/core/models/relation_schema
source/core/models/relational_datum_entity
source/core/models/user_switch_models
source/core/models/vector_clock
source/core/persistence/datum_persistence
source/core/persistence/in_memory_datum_persistence
source/core/query/datum_query
source/core/query/datum_query_builder
source/core/query/datum_query_matcher
source/core/query/datum_query_sql_converter
source/core/query/datum_raw_query
source/core/resolver/conflict_resolution
source/core/resolver/crdt_resolver
source/core/resolver/last_write_wins_resolver
source/core/resolver/local_priority_resolver
source/core/resolver/merge_resolver
source/core/resolver/remote_priority_resolver
source/core/resolver/user_prompt_resolver
source/core/schema/datum_field_codec
Bidirectional converters between Dart field values and their persisted (map / wire) representation, used by DatumFieldSpec โ€” the no-codegen type-safety layer.
source/core/schema/datum_field_spec
Runtime field descriptors โ€” the no-codegen alternative to datum_generator's per-field output.
source/core/schema/datum_relation_spec
Typed relation descriptors โ€” the no-codegen way to declare, load, and fetch relations without stringly-typed names, foreign keys, or casts.
source/core/schema/datum_schema
The runtime schema declaration โ€” one object per entity type powering typed map reads, SQLite column derivation, and auto-migration, without code generation.
source/core/schema/datum_schema_reader
Typed access to a raw persisted map, driven by DatumFieldSpecs.
source/core/sync/datum_sync_execution_strategy
source/core/utils/lru_cache
source/utils/connectivity_checker
source/utils/datum_logger
source/utils/duration_formatter
source/utils/hash_generator