# Синхронизация ## Цель Один аккаунт Balance может использоваться на нескольких устройствах: ```text iPhone │ ├── transaction A ├── category B └── budget C │ ▼ BalanceServer │ ┌────┴────┐ ▼ ▼ Android Mac ``` ## Cursor Каждый клиент хранит cursor последнего успешно применённого server change. Принцип: ```text cursor = 100 server: 101 A 102 B 103 C pull(cursor=100) → A, B, C → new cursor = 103 ``` Клиент должен сохранять cursor только после успешного применения соответствующего batch. ## Sequence Server sequence задаёт глобальный порядок изменений в sync stream. Требования: - монотонность; - отсутствие уменьшения; - однозначность порядка; - корректность при concurrent requests. ## Push Push передаёт изменения клиента: ```text local mutations ↓ POST /v1/sync ↓ validation ↓ ownership check ↓ persist ↓ append to change stream ``` Retry не должен создавать две разные логические записи из одного client change. ## Pull Pull получает изменения после cursor. Сервер не должен возвращать изменения другого пользователя. При pagination клиент должен иметь возможность продолжить с нового cursor. ## Conflict resolution Клиенты Balance используют timestamp-based versioning. Типовая проверка: ```text local.updatedAt < remote.updatedAt ``` Следовательно, server должен сохранять `updatedAt` достаточно точно и не подменять его произвольным временем каждого чтения. При конкурентных writes сервер должен иметь однозначное правило победителя. ## Deletions Удаление создаёт tombstone/change: ```json { "entity": "transaction", "id": "...", "deleted": true, "updatedAt": "..." } ``` Tombstone должен попасть в change stream так же, как обычное изменение. ## Idempotency Push может повториться из-за: - timeout; - network failure; - app restart; - retry after 401/token refresh. Поэтому сервер должен обрабатывать повторную отправку безопасно. ## Batch Apple-клиент отправляет batches до 100 изменений и может делить крупные payloads. Android должен использовать совместимый protocol. Server не должен предполагать, что все изменения приходят одним запросом. ## Reset/full sync Если client потерял cursor или требует reset, сервер должен поддерживать предусмотренный текущим API механизм полного pull либо новый cursor baseline. Нельзя молча трактовать неизвестный/просроченный cursor как актуальный. ## Пространства (контракт v2) `POST /v1/sync` работает в одном пространстве данных: - без `X-Balance-Ledger` — личное пространство пользователя, таблицы `records`/`changes`, поведение v1 без изменений; - с `X-Balance-Ledger: ` — общее пространство, таблицы `ledger_records`/`ledger_changes`. Доступ проверяется на каждый запрос. У общего пространства свой поток изменений: `sequence` берётся из отдельного AUTOINCREMENT `ledger_changes`, а pull фильтрует по `ledger_id`. Курсор личного пространства и курсор каждого ledger независимы, клиент хранит их раздельно. Все участники пишут в одно пространство с тем же last-write-wins по `updatedAt`; идемпотентность, пагинация (500), лимиты и транзакционность те же. Удаление пространства физически удаляет его записи и историю изменений. Удалённый участник теряет доступ (`403`), записи остаются в пространстве. ## Вложения и сборка мусора Байты вложений загружаются отдельно (`PUT /v1/attachments/{sha256}`) до записи `attachment` в sync. Файл пространства считается мусором, когда ни одна живая (`deleted=0`) запись `attachment` этого пространства не ссылается на его `sha256` (сравнение без учёта регистра). Сборка выполняется в той же транзакции, что и sync-запрос, содержащий изменения `attachment`, и при каждом PUT. Файл, ни разу не получивший ссылки, хранится не меньше 24 часов с последней загрузки, чтобы не удалить байты, чья запись ещё не синхронизирована; повторная загрузка перезапускает этот срок. ## Security Sync endpoint всегда ограничен текущим authenticated user. `deviceId` — идентификатор устройства, а не механизм авторизации.