# Спецификация 03: SyncQueue и ApiClient Источники: Ф-7, tech-req 5 (SyncQueue: пакеты ≤ 50, WorkManager, ретрай 1→64 мин, WifiOnly), m02 spec 03 (POST /api/v1/sync). ## 1. Очередь - `sync_pending(client_guid PK, entity, payload JSON, created_at)`; запись — только из WorkoutCore (spec 02 §5); - **Batcher** (чистая функция, unit-тест): `fun batch(rows: List, max: Int = 50): List>` — по возрастанию created_at, порциями ≤ max. ## 2. SyncWorker (WorkManager, Expedited/OneTime) 1. Прочитать порции Batcher'ом; 2. для каждой: `POST {BaseUrl}/api/v1/sync` c `X-Api-Key`, тело — JSON-массив конвертов (spec 04 §1); 3. ответ 200 `{"accepted":[guid…]}` → `deleteAll(accepted)` (частично принятый пакет — удалить принятые, rest останется); 4. 401/409 — **не ретраить автоматически**: 401 — флаг `authError` (баннер m10 «ключ неверен»), 409 — конфликт версии программы (пользовательское разрешение — m10; запись остаётся); 5. сеть/5xx — Result.retry() c backoff WorkManager (`setInitialDelay` экспоненциально 1→64 мин, policy LINEAR по умолчанию меняем на ручной расчёт от attemptCount); 6. `Sync.WifiOnly=true` → constraint UNMETERED; 7. триггеры: enqueue записи, восстановление сети (constraint), запуск приложения (m10 подпишется). ## 3. ApiClient (Retrofit + OkHttp + kotlinx-serialization) ```kotlin interface WatchBodyApi { @POST("api/v1/sync") suspend fun sync(@Body items: List): SyncResponse @GET("api/v1/programs/active") suspend fun activeProgram(): ActiveProgramResponse? } ``` - BaseUrl из DataStore (`Server.BaseUrl`), интерцептор `X-Api-Key`; - `SyncResponse { accepted: List }`; - `activeProgram` — «тяни и держи»: ответ маппится в `program_version` LocalDb (status=active, локальный uuid клиента — серверный versionId приходит в теле; хранение по нему), вызов — при старте приложения/после sync (m10); 404 → активной нет. ## 4. Инварианты 🛡 - сеть только здесь (офлайн-first, 🛡1); - удаление из очереди — только по подтверждению сервера (🛡4); - API-ключ не логируется (🛡5; OkHttp-лог выключен в release).