> ## Documentation Index
> Fetch the complete documentation index at: https://cindel.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Cindel Local-First Sync: Adapter, Config, and Offline Writes

> Add local-first sync to Cindel by implementing CindelSyncAdapter. Covers push and pull contract, offline behavior, and common mistakes.

Cindel Sync is an experimental local-first replication layer that lets your app keep working offline while automatically reconciling local changes with a backend you control. You write to typed collections as normal; Cindel records those mutations, pushes them to your backend through a `CindelSyncAdapter` you implement, pulls remote changes, and applies backend corrections back into the local database — all without any code change to your reads or watchers.

## What Sync Is and Is Not

Understanding the boundaries of Cindel Sync will save you time when designing your backend.

**Sync gives you:**

* **Local-first optimistic writes** — every write is visible in the local database immediately, before any network round trip.
* **Offline support** — local writes still commit and reads return optimistic data while the device is offline.
* **Backend corrections applied locally** — if your backend normalizes or corrects a document, Cindel applies that truth locally and watchers fire automatically.
* **A durable outbox** — pending mutations survive app close and reopen. When connectivity returns, the scheduler retries automatically.

**Sync does NOT provide:**

* A hosted sync server — you supply the backend.
* Conflict resolution rules — your backend decides who wins.
* Authentication or access control — your adapter is responsible for auth.
* Multi-tab coordination on Web — watcher delivery is single-tab even when sync is active.

<Warning>
  Cindel Sync is currently **experimental**. The adapter contract and configuration API may change between minor releases.
</Warning>

## Enabling Sync

Pass a `CindelSyncConfig` to `Cindel.open`. Everything else — reads, writes, watchers, transactions — stays the same.

```dart theme={null}
final db = await Cindel.open(
  directory: dir.path,
  schemas: [TodoSchema],
  sync: CindelSyncConfig(
    adapter: MyBackendAdapter(),
    clientId: 'stable-device-id', // must be stable across restarts
    autoStart: true,
    interval: Duration(seconds: 30),
    batchSize: 100,
    onStatusChanged: (status) => print(status),
    onError: (error, stackTrace) => print(error),
  ),
);
```

After opening, read and write normally. Cindel captures supported local writes automatically — do not create sync mutations by hand.

```dart theme={null}
await db.todos.put(todo);

final all = await db.todos.all().findAll();

final sub = db.todos.watchCollection().listen((todos) {
  // Fires for local optimistic writes and later remote corrections.
});
```

## `CindelSyncConfig` Fields

| Field             | Type                | Description                                             |
| ----------------- | ------------------- | ------------------------------------------------------- |
| `adapter`         | `CindelSyncAdapter` | Your backend push/pull implementation                   |
| `clientId`        | `String`            | Stable device/user ID — must NOT change across restarts |
| `autoStart`       | `bool`              | Whether to start syncing immediately on open            |
| `interval`        | `Duration`          | Polling interval between sync cycles                    |
| `batchSize`       | `int`               | Max mutations per push batch                            |
| `onStatusChanged` | callback            | Notified on sync status phase changes                   |
| `onError`         | callback            | Notified on background sync errors                      |

<Note>
  If you omit `clientId`, Cindel persists an internal ID for this local database. Provide an explicit `clientId` when your backend needs to track which device sent a mutation — for example, to apply per-device access control or ordering rules.
</Note>

## Sync Status

The `onStatusChanged` callback receives a `CindelSyncStatus` object you can use for UI indicators and logging.

```dart theme={null}
final class CindelSyncStatus {
  final CindelSyncPhase phase;
  final int pendingCount;
  final DateTime? lastSyncAt;
  final Object? lastError;
}
```

The `phase` field is one of:

| Phase     | Meaning                                               |
| --------- | ----------------------------------------------------- |
| `idle`    | No sync call is currently running                     |
| `syncing` | Cindel is actively calling the adapter                |
| `offline` | The adapter failed with an offline-style error        |
| `error`   | The adapter or remote apply failed for another reason |

Use `pendingCount` to show a "2 changes pending" badge. Use `lastSyncAt` to show a "last synced" timestamp. Always use typed reads and watchers as the source of application data — do not drive UI state from sync callback payloads.

## Implementing `CindelSyncAdapter`

Implement the `CindelSyncAdapter` interface by providing two methods: `push` and `pull`.

```dart theme={null}
final class MyBackendAdapter implements CindelSyncAdapter {
  MyBackendAdapter(this.serverBaseUri);

  final Uri serverBaseUri;

  @override
  Future<CindelPushResult> push(CindelPushRequest request) async {
    // Send local mutations to your backend and return the result.
  }

  @override
  Future<CindelPullResult> pull(CindelPullRequest request) async {
    // Fetch remote changes after the local checkpoint and return them.
  }
}
```

### Push Flow

`push` receives a `CindelPushRequest` containing the pending local mutations and expects a `CindelPushResult` in return.

**`CindelPushRequest` fields:**

* `clientId` — the local client/device ID
* `lastPulledCheckpoint` — the last checkpoint this database pulled
* `schemaVersionByCollection` — current local schema versions by collection name
* `mutations` — the list of `CindelSyncMutation` objects to send

**Each `CindelSyncMutation` contains:**

| Field              | Description                                      |
| ------------------ | ------------------------------------------------ |
| `mutationId`       | Stable ID for retry and deduplication            |
| `clientId`         | The client that created the mutation             |
| `sequence`         | Local sequence number for this client            |
| `collection`       | Collection name, e.g. `todos` or `orderLines`    |
| `operation`        | `upsert`, `delete`, or `replaceLinks`            |
| `documentId`       | Document ID for document mutations               |
| `document`         | Stored document map for upserts                  |
| `linkName`         | Link name for `replaceLinks` mutations           |
| `targetCollection` | Target collection for `replaceLinks` mutations   |
| `targetIds`        | Target document IDs for `replaceLinks` mutations |
| `baseCheckpoint`   | Checkpoint known when this mutation was recorded |

**Return a `CindelPushResult`:**

```dart theme={null}
return CindelPushResult(
  acceptedMutationIds: {'device-a:1', 'device-a:2'},
  rejectedMutations: [
    CindelSyncRejectedMutation(
      mutationId: 'device-a:3',
      reason: 'product_not_available',
    ),
  ],
  correctedChanges: [
    CindelRemoteUpsert(
      collection: 'todos',
      id: 42,
      document: {
        'dbId': 42,
        'title': 'Buy milk (corrected)',
        'completed': false,
      },
    ),
  ],
  checkpoint: '99',
);
```

* `acceptedMutationIds` — tells Cindel which pending mutations can be removed from the outbox.
* `rejectedMutations` — for mutations your backend will permanently never accept. Reserve rejections for business failures, not temporary network errors.
* `correctedChanges` — lets your backend return final truth immediately, such as a server-normalized document.
* `checkpoint` — optional on push. If provided, Cindel stores it as the newest known checkpoint.

### Pull Flow

`pull` receives a `CindelPullRequest` and expects a `CindelPullResult` containing every remote change after the local checkpoint.

**`CindelPullRequest` fields:** `clientId`, `checkpoint`, `collections`, `schemaVersionByCollection`.

**Return a `CindelPullResult`:**

```dart theme={null}
return CindelPullResult(
  checkpoint: '100',
  changes: [
    CindelRemoteUpsert(
      collection: 'todos',
      id: 7,
      document: {'dbId': 7, 'title': 'From another device', 'completed': false},
    ),
    CindelRemoteDelete(collection: 'todos', id: 3),
    CindelRemoteReplaceLinks(
      collection: 'projects',
      id: 1,
      linkName: 'members',
      targetCollection: 'users',
      targetIds: [10, 11],
    ),
  ],
);
```

Use `resetRequired: true` only when the backend cannot safely continue from the client's checkpoint. For normal incremental sync, leave it as the default `false`.

<Note>
  The `collection` field in every remote change must match a schema registered at `Cindel.open`. Cindel refuses to apply changes for unknown collections.
</Note>

## Offline Behavior

If your adapter cannot reach the server, throw an error from `push` or `pull`. Cindel will catch it and retry later according to `interval`.

While offline:

* Local writes still commit immediately.
* Typed reads return local optimistic data.
* Watchers emit local changes.
* `pendingCount` increases as mutations queue in the outbox.
* The sync `phase` may become `offline`.
* Pending mutations survive app close and reopen.

When the server is reachable again, the scheduler retries automatically — no manual intervention required.

## Supported Local Operations While Sync Is Active

Cindel records the following local operations as sync mutations:

* Typed `put` and `putAll`
* Typed `delete` and `deleteAll`
* Query deletes
* Link replacements

<Warning>
  **Query updates (`updateFirst` / `updateAll`) are NOT supported while sync is enabled.** They do not produce complete document snapshots, so Cindel cannot build a valid mutation from them.

  Instead of:

  ```dart theme={null}
  // ❌ Not supported with sync enabled
  await db.todos.all().updateAll({'completed': true});
  ```

  Read the objects, mutate them in Dart, then write them back:

  ```dart theme={null}
  // ✅ Correct approach
  final todos = await db.todos.all().findAll();
  for (final todo in todos) {
    todo.completed = true;
  }
  await db.todos.putAll(todos);
  ```
</Warning>

## Minimal Server Requirements

A production sync backend should provide:

* **Durable storage** for accepted mutation IDs and document state.
* **Idempotent `mutationId` handling** — if the same mutation arrives twice, accept it again without applying it twice.
* **Stable checkpoints** — clients must be able to pull all changes after a given checkpoint reliably.
* **Access control and authentication** — your adapter handles auth; your backend enforces it.
* **Conflict and correction rules** — your backend decides the final document shape.

Start with two endpoints:

* `POST /sync/push` — accepts local mutations, returns accepted IDs and optional corrections.
* `POST /sync/pull` — returns remote changes after a checkpoint.

## Testing Sync

Use a `FakeSyncAdapter` with an `online` toggle to test sync behavior without a real server. See the [Testing](/testing) page for a full guide.

```dart theme={null}
final adapter = FakeSyncAdapter()..online = false;
final statuses = <CindelSyncStatus>[];

final db = await Cindel.open(
  directory: dir.path,
  schemas: [TodoSchema],
  sync: CindelSyncConfig(
    adapter: adapter,
    interval: const Duration(milliseconds: 10),
    onStatusChanged: statuses.add,
  ),
);

// Local write is visible immediately even while offline.
await db.todos.put(todo);
expect(await db.todos.get(todo.dbId), isNotNull);

// Bring the adapter online and let the scheduler sync.
adapter.online = true;
```

## Common Mistakes

<Accordion title="Generating a new clientId on every app start">
  If your backend uses `clientId` to track devices or enforce ordering, a new random ID on every launch means every run looks like a brand-new device. Previously sent mutations become orphaned — the backend has no way to deduplicate them. Generate the `clientId` once, persist it (e.g. in `SharedPreferences`), and reuse it across restarts.
</Accordion>

<Accordion title="Updating UI from sync callbacks instead of watchers">
  The `onStatusChanged` and `onError` callbacks are for indicators and logging only. Do not drive application data from their payloads. Use typed Cindel watchers (`watchCollection`, `watchObject`, etc.) as the single source of truth for your UI — they fire automatically when remote corrections arrive.
</Accordion>

<Accordion title="Accepting duplicate mutationIds on the server">
  Your backend must treat `mutationId` as an idempotent key. If the network drops after your backend applies a mutation but before the adapter returns, Cindel will retry with the same `mutationId`. Accepting it twice will corrupt data. Always check whether a `mutationId` was already applied before processing it again.
</Accordion>

<Accordion title="Returning remote changes for unregistered collections">
  Every collection name in `CindelRemoteUpsert`, `CindelRemoteDelete`, or `CindelRemoteReplaceLinks` must match a schema you passed to `Cindel.open`. If the name doesn't match, Cindel will refuse to apply the change. Make sure your server only returns collections the client has registered.
</Accordion>

<Accordion title="Using query updates while sync is enabled">
  `updateFirst` and `updateAll` are not supported when sync is active. They produce partial field patches, not complete document snapshots, so Cindel cannot build a valid mutation. Read the documents, modify them in Dart, and call `put` or `putAll` to write them back.
</Accordion>
