Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

Repository files navigation

🚀 Appwrite Isar Sync Engine

A plug-and-play, domain-agnostic sync engine for Flutter offline-first apps.

This package automates the bridge between Isar (local embedded database) and Appwrite (backend as a service), keeping your data consistent across devices even under unstable connectivity.


💡 Why use this?

Building offline-first apps involves a lot of moving parts:

  • Persistent local cache
  • Pushing local changes when connectivity returns (reconciliation)
  • Receiving remote changes in real time
  • Conflict resolution (race conditions)
  • Multi-tenancy: ensuring users only see their own data

This engine encapsulates all of that complexity into a declarative configuration, so you can focus on your app features instead of sync infrastructure.


🏗️ Architecture

The engine follows the Single Source of Truth (SSOT) principle: your UI reads and writes exclusively to Isar. The engine mirrors those changes to Appwrite in the background.

Flutter UI <--> Isar (local DB)
                  |
            Sync Engine
           /           \
   Push (pending)    Pull (remote)
        |                 |
      Appwrite       Realtime events

Data flow:

  1. UI writes to Isar with syncStatus = SyncStatus.pending
  2. Isar watcher triggers the push for that collection
  3. On initialize(), the engine pulls all remote documents (reconcile)
  4. Appwrite Realtime pushes incoming changes as they happen

📦 Installation

Add to your pubspec.yaml:

dependencies:
  appwrite_isar_sync_engine:
    git:
      url: https://github.com/egionCode/appwrite_isar_sync_engine.git
      ref: main

🛠️ Setup

1. Implement SyncableModel on your Isar collections

Any collection that needs to be synced must implement this interface:

import 'package:appwrite_isar_sync_engine/appwrite_isar_sync_engine.dart';

@Collection()
class Note implements SyncableModel {
  // Isar internal auto-increment ID
  Id isarId = Isar.autoIncrement;

  // Your document ID (UUID or Appwrite's $id). Must be unique.
  @Index(unique: true, replace: true)
  @override
  late String id;

  late String content;

  // Required by SyncableModel: used for conflict resolution
  @override
  late DateTime updatedAt;

  @Enumerated(EnumType.name)
  @override
  late SyncStatus syncStatus;
}

SyncableModel contract:

Member Type Purpose
id String Appwrite document ID
updatedAt DateTime Timestamp for conflict resolution
syncStatus SyncStatus (get + set) Tracks local sync state

SyncStatus values: synced, pending, error.


2. Configure and initialize the engine

final engine = AppwriteIsarSyncEngine(
  databases: appwriteDatabases,
  realtime: appwriteRealtime,
  isar: localIsar,
  collections: [
    SyncCollectionConfig<Note>(
      databaseId: 'main_db',
      collectionId: 'notes',

      // Isar model -> Appwrite document payload
      toRemoteMap: (note) => {
        'content': note.content,
        'updatedAt': note.updatedAt.toIso8601String(),
      },

      // Appwrite document -> Isar model
      fromRemoteMap: (data, existing) => Note()
        ..isarId = existing?.isarId ?? Isar.autoIncrement
        ..id = data['\$id']
        ..content = data['content']
        ..updatedAt = DateTime.parse(data['updatedAt']),

      // Closures that isolate Isar query knowledge inside your app
      getPendingItems: (isar) =>
          isar.notes.where().syncStatusEqualTo(SyncStatus.pending).findAll(),
      getLocalById: (isar, id) =>
          isar.notes.where().idEqualTo(id).findFirst(),
      saveLocal: (isar, note) =>
          isar.writeTxn(() => isar.notes.put(note)),
      watchPendingItems: (isar) =>
          isar.notes.where().syncStatusEqualTo(SyncStatus.pending).watch(),

      // Optional: Appwrite query filters applied on every pull
      getPullQueries: () => [
        Query.equal('userId', currentUserId),
      ],
    ),
  ],
);

await engine.initialize();

Call engine.dispose() when the engine is no longer needed (e.g., on user logout):

engine.dispose();

⚡ Features

🛡️ Conflict resolution: Latest Write Wins

When a remote document arrives (via Realtime or Pull), the engine compares updatedAt timestamps:

  • Remote is newer: local record is overwritten
  • Local is newer: remote is ignored
  • Same timestamp and local is already synced: no write

🔄 Upsert strategy

When pushing a pending item, the engine checks if the document exists in Appwrite first. If it does not exist (404), it creates it. Otherwise, it updates it. This handles the case where Appwrite documents were deleted remotely.

📄 Paginated pull

Remote documents are fetched in pages of 100. All pages are processed before the reconcile cycle completes.

👥 Multi-tenancy

Use getPullQueries to pass dynamic Appwrite query filters (e.g., Query.equal('userId', uid)). The engine applies them on every pull, so each user only syncs their own data.

🗑️ Soft deletes

Do not call isar.delete() directly on records that need to propagate deletion across devices. Instead, add an isDeleted field to your model and, when the user deletes a record:

  1. Set isDeleted = true
  2. Set updatedAt = DateTime.now()
  3. Set syncStatus = SyncStatus.pending

The engine will push the update to Appwrite. Other devices receive it via Realtime or Pull and can hide the record accordingly.

📊 UI state monitoring

The engine exposes a ValueNotifier<SyncState> you can listen to:

ValueListenableBuilder<SyncState>(
  valueListenable: engine.stateNotifier,
  builder: (context, state, _) {
    if (state.isSyncing) {
      return LinearProgressIndicator(value: state.progress);
    }
    if (state.error != null) {
      return Text('Sync error: ${state.error}');
    }
    return const SizedBox.shrink();
  },
);

SyncState fields:

Field Type Description
isSyncing bool Whether a sync cycle is in progress
currentTask String Description of the current operation
progress double Progress from 0.0 to 1.0
error String? Last error message, if any

🌏 Using in a new project

The engine has no knowledge of your domain models. To wire it up:

  1. Add the dependency
  2. Implement SyncableModel on any Isar collection you want to sync
  3. Provide closures in SyncCollectionConfig that perform Isar operations for that specific collection

The engine calls those closures instead of accessing your generated Isar code directly, keeping the package fully decoupled from your schema.


Made with ❤️ by Victor / egionCode.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages