# Sync
The sync skeleton is a push-then-pull loop over one resource, for an app that keeps a local store consistent with a server across many devices.
- [Introduction](#introduction)
- [The Wire Protocol](#the-wire-protocol)
- [Writing a Feed](#writing-a-feed)
- [Registering the Ledger's Table](#registering-the-ledgers-table)
- [The Two-Clock Rule](#the-two-clock-rule)
- [The Savepoint Note](#the-savepoint-note)
- [What Stays App-Side](#what-stays-app-side)
## Introduction
`SyncFeed` runs one resource's sync: it pushes everything this device wrote since its last push, then, only when that push had no failure, pulls everything the server has past this device's last pull. It never throws, and answers a `SyncReport` (`pushed`, `adopted`, `failure`), logged through `Log.error` on the way out rather than propagated, because a sync is a convenience layered on top of a local store that already works without it.
```dart
final SyncReport report = await ItemsSyncFeed().run(scope: 'team-42', account: userId);
if (!report.complete) {
Log.warning('items sync did not finish: ${report.failure}');
}
```
`SyncLedger` is the bookkeeping `SyncFeed` reads and writes between runs, backed by the `sync_cursors` table `CreateSyncCursorsTable` creates.
## The Wire Protocol
**Push.** `POST '$resource/sync'` with `{envelopeKey: [row.data, ...]}`, batched at `SyncFeed.batchSize` (default 500) rows per request. The push response is read the same way a pull page is (`{"data": [...]}`), so a write this device lost to a conflicting write elsewhere adopts the winner immediately, without waiting for the next pull.
**Pull.** `GET resource` with query `{scope, cursor}`, `cursor` omitted when there is none yet. The pull response is `{"data": [...], "cursor": , "has_more": bool}`. `cursor` is taken from the body rather than derived from the last row: the pair it encodes is the server's business, and a client that built one would be guessing at a tie-break it cannot see. A run walks pages until `has_more` is false or `SyncFeed.maxPages` (default 100) is reached.
Push runs first, and the pull runs only after a push with no failure: a failed push returns before the pull ever starts. The push makes the server's answer to the pull already reflect this device's own writes, so one round leaves the device consistent. Pulling first would leave a device that had just resolved a conflict holding a stale view until the next run.
## Writing a Feed
A feed subclasses `SyncFeed` and supplies three things: where its bookmarks live (`feed`), which endpoint it talks to (`resource`, `envelopeKey`), and how to read and write its own rows (`pending`, `adoptRow`).
```dart
import 'package:magic/magic.dart';
class ItemsSyncFeed extends SyncFeed {
@override
String get feed => 'items';
@override
String get resource => 'items';
@override
String get envelopeKey => 'items';
@override
Future> pending({
required String account,
required int sinceMillis,
required String scope,
}) async {
final List