isar_sync

Offline-first synchronization framework for Isar-based apps.

Isar stays your local source of truth. isar_sync records every change as an event, ships those events through a pluggable remote adapter (Google Drive, iCloud, or your own), and applies remote events back into your store — so the same data converges across a user's devices, with no server to run.

Install

dependencies:
  isar_sync: ^0.1.0-dev.3

or

dart pub add isar_sync

Quick Start

import 'package:isar_sync/isar_sync.dart';

const deviceId = 'device-A'; // stable per install

final queue = InMemorySyncQueue();
final store = InMemorySyncStore();
final cursorStore = InMemorySyncCursorStore();

final applyEngine = DefaultApplyEngine(
  store: store,
  conflictResolver: const LastWriteWinsConflictResolver(),
  // Drop this device's own events echoed back from shared cloud storage.
  // REQUIRED for the Google Drive / iCloud adapters.
  localDeviceId: deviceId,
);

// Replace with a real adapter: GoogleDriveSyncAdapter or ICloudSyncAdapter.
final adapter = MySyncAdapter();

final core = SyncCore(
  queue: queue,
  adapter: adapter,
  applyEngine: applyEngine,
  cursorStore: cursorStore,
);

// Capture a local change...
final capture = QueueBackedChangeCapture(queue: queue, deviceId: deviceId);
await capture.capture(
  SyncMutation(
    collection: 'notes',
    entityId: 'n1',
    operation: SyncOperation.upsert,
    payload: {
      'title': 'hello',
      // A UTC updatedAt is required for correct Last-Write-Wins.
      'updatedAt': DateTime.now().toUtc().toIso8601String(),
    },
  ),
);

// ...then run one push + pull + apply cycle.
final report = await core.syncOnce();
print('pushed=${report.pushedCount}, pulled=${report.pulledCount}');

Two conventions are load-bearing: every upsert payload must carry a UTC updatedAt, and DefaultApplyEngine.localDeviceId must be set (and equal to the capture deviceId) for shared-folder cloud adapters. See doc/usage.md §10.

Flutter companion

For ready-made platform integrations (Google Sign-In, icloud_storage), use the companion package:

dependencies:
  isar_sync: ^0.1.0-dev.3
  isar_sync_flutter: ^0.1.0-dev.3

Adapter & integration guides

Sign in on another device with the same Google account or Apple ID and use the same namespace, and the two devices share events through that remote store.

Why

Isar is an excellent local database, but as an app grows the gaps show:

  • no multi-device sync,
  • no change log,
  • collaboration/sharing is hard to build.

Teams usually end up adding a backend (more complexity), hand-rolling sync (low reuse, high maintenance), or running servers they didn't want. The core problem:

Local-first is great, but the next step is missing.

isar_sync does not replace Isar. It keeps Isar as-is and adds sync on top: start with no server, extend only when needed, and change as little existing code as possible.

Conceptual model

  • State vs Change — Isar holds the current state; isar_sync records the changes. That separation is what makes debugging, reprocessing, and re-syncing possible.
  • Event-driven — every mutation is captured as an event: state change → event → sync.
  • Eventually consistent — the goal is convergence, not instantaneous agreement.

End-to-end flow

  1. A local change happens (user edits data).
  2. Change capture — the Isar write and the SyncEvent are created in one transaction boundary, so data and event are atomic.
  3. The event is queued in a SyncQueue (not yet on the remote).
  4. PushSyncQueue → adapter → remote storage.
  5. Pull — remote storage → adapter → incoming events.
  6. Events are de-duplicated and ordered by timestamp.
  7. Apply — events are applied to the store (upsert/delete), with conflicts resolved by the ConflictResolver.
  8. UI updates via Isar's watch.
flowchart LR
  U[User action] --> W[Isar write]
  W --> C[Change capture]
  C --> E[SyncEvent]
  E --> Q[SyncQueue]
  Q --> P[Push]
  P --> A1[Adapter]
  A1 --> R[(Remote storage)]

  R --> A2[Adapter]
  A2 --> PL[Pull]
  PL --> F[Dedup / order]
  F --> AP[Apply engine]
  AP --> I[Isar update]
  I --> UI[UI refresh]

Components

Component Responsibility
SyncCore Orchestrates one push → pull → apply cycle (syncOnce())
QueueBackedChangeCapture Turns local mutations into queued SyncEvents
SyncQueue Outbound queue with retry/backoff (InMemorySyncQueue, FileSyncQueue)
SyncAdapter Abstracts the remote store (GoogleDriveSyncAdapter, ICloudSyncAdapter)
ApplyEngine Applies remote events to local state
ConflictResolver Conflict policy (default LastWriteWinsConflictResolver)
SyncCursorStore Persists the per-adapter pull cursor (FileSyncCursorStore)

Design principles

  • Simplicity first — an understandable pipeline over complex CRDTs.
  • Predictability — explicit overwrite rules and apply ordering.
  • Debuggability — an event log you can trace and reprocess.
  • Incremental — v1 is simple sync; advanced policies/optimizations come later.

Scope

In v1: the event pipeline (capture → queue → push → pull → apply), extensible ports (SyncAdapter, SyncStore, ConflictResolver, SyncCursorStore), default Last-Write-Wins resolution, and explicit syncOnce() execution.

Not in v1: fully automatic real-time sync, CRDT-based concurrent editing, global total ordering, and backend-standardized auth/permissions.

Status

Early preview (0.1.0-dev). The API is still evolving.

Docs

Publishing guide: doc/publish-to-pub.md · Version history: CHANGELOG.md

Libraries

isar_sync