Готовые движки: WatermelonDB и PowerSync
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Протокол «Подхода» собран целиком: имена операций рождаются на телефоне, курсор — номер сервера, вердикт приходит по каждой операции, удаление — это запись, а спорные правки складывает одно правило на обеих сторонах. Всё это давно написано готовыми библиотеками, и теперь их документация читается не как магия, а как чужой пересказ знакомого. Двух хватит, потому что устроены они противоположно: WatermelonDB возит состояние — записи целиком, без всякого журнала, — а PowerSync возит операции, и его очередь CRUD это буквально ваша исходящая очередь. Переходник из журнала «Подхода» в их форматы показывает цену каждой модели там, где её видно лучше всего: в том, что при переводе теряется. Отдельно — врезка про кеш запросов, который движком синхронизации не является, хотя его часто просят им побыть.
У WatermelonDB нет журнала операций. Совсем: ни таблицы изменений, ни очереди — есть записи, у которых две служебные колонки. Они объявлены в RawRecord/index.d.ts и стоят у каждой записи любой таблицы: _status — что с записью случилось с прошлой синхронизации, _changed — имена изменённых колонок через запятую. Удаление тоже состояние: markAsDeleted() помечает запись, и в типах рядом с ним стоит примечание авторов «Use this only with Sync», а рядом с destroyPermanently() — обратное, «Don't use this when using Sync». Это наше надгробие из статьи «Удаление — это запись», только служебное и спрятанное от вас.
Синхронизация здесь — одна функция, и вся её форма видна в типах:
Узнаваемого много. pullChanges — это sync.pull, только курсор у него не номер сервера, а время: lastPulledAt приходит из прошлого ответа, новое сервер возвращает в timestamp. Чем опасно время вместо номера, глава разобрала в первой же статье, «Сервер — партнёр, а не хозяин»; спасает тут ровно одно — что время это серверное, одно на всех, а не показания часов телефона. pushChanges — это sync.push, но ответ у него куда беднее: experimentalRejectedIds, список отвергнутых идентификаторов записей по таблицам. Ни причины, ни вердикта по каждой операции — потому что операций здесь нет вовсе, и слово «experimental» в имени стоит не случайно.
Переведём журнал «Подхода» в этот формат. Телефон записал два подхода и синхронизировался; потом, уже без сети, поправил вес в первом, добавил третий и удалил второй. Переходнику достаются журнал, исходящая очередь и хранилище. Прогноз: в какой из трёх ключей попадёт каждая строка, что окажется внутри updated — одно поле или целая строка — и найдутся ли в changes имя операции и отметка времени?
Три строки разошлись по трём ключам ровно так, как ждёт synchronize: новая — в created, правленая — в updated, удалённая — в deleted, и там только идентификатор. А дальше начинается цена. В updated уехала вся строка целиком — упражнение, вес и повторы, — хотя человек трогал один вес. Это не вольность переходника: в исходнике sync/impl/fetchLocal.js так и написано, changeSet.updated.push(Object.assign({}, record._raw)), а рядом честный TODO авторов: «It would probably also be good to only send to server locally changed fields, not full records».
Вторая половина цены — в последней строке прогона. Ни opId, ни at в changes нет, и положить их некуда: формат описывает состояние записи, а не изменение. Значит, всё, что глава на них построила, придётся строить заново и снаружи. Память вердиктов из статьи «Ответ потерялся» ключевать нечем: у повторной пачки те же идентификаторы записей, что у первой, и отличить повтор от новой правки сервер не сможет. Слияние по отметкам полей из статьи «Кто прав, если оба правы» серверу тоже не из чего собрать — он получает готовую строку.
| что едет по сети | журнал «Подхода» | changes WatermelonDB |
|---|---|---|
| единица | операция: только изменённые поля | запись целиком |
| имя для повтора | opId «устройство + счётчик» | нет, только id записи |
| отметка времени | at у каждого поля | нет |
| удаление | операция { deleted: true } | ключ deleted со списком id |
| ответ на пачку | вердикт по каждой операции с причиной | experimentalRejectedIds по id записей |
Служебные колонки всё-таки уезжают на сервер: в том же fetchLocal.js стоит TODO «It would be best to omit _status, _changed fields». А вот присылать их обратно нельзя — applyRemote.js проверяет пришедшее и ругается, что записи «must NOT have a '_status' or '_changed' fields». Мелочь, на которой спотыкается первый же самописный сервер к WatermelonDB.
В pullChanges приходит не только курсор: schemaVersion и migration говорят серверу, с какой версии схемы поднимается телефон, а migrationsEnabledAtVersion в настройках synchronize включает этот разговор — в комментарии к полю сказано, что это «version BEFORE first syncable migration». Требование к серверу отсюда то же самое, что у нас: он обязан отдать изменения так, чтобы их понял телефон со старой схемой. Лестница миграций на устройстве — «Диск под памятью: перезапуск и миграция схемы». Но переименовать поле одним шагом, как там, синхронизируемой схеме нельзя: установленные старые версии месяцами шлют записи старой формы. Поэтому миграции здесь расширяющие — новое поле появляется рядом со старым, — а порядок таких выпусков разберёт статья «Выпуск: сборка, магазин, первое обновление» в главе 12.
Кажется, что модель «по состоянию» обязана решать споры целой строкой — тем самым «последняя запись побеждает», которое статья «Кто прав, если оба правы» разобрала как худшее из правил. Это не так, и сказать об этом надо честно: у WatermelonDB слияние тоже по полям. Алгоритм лежит в sync/impl/helpers.js, и авторы описали его комментарием прямо над функцией: «This is a per-column resolution algorithm. All columns that were changed locally win and will be applied on top of the remote version».
Вот в этом «changed locally win» и разница. Наше правило сравнивает отметки [at, opId] и работает одинаково на обеих сторонах. Правило WatermelonDB спрашивает не «когда», а «моё ли»: колонки, перечисленные в _changed, ложатся поверх пришедшей версии. Проверим на одном случае: на телефоне поправили вес, а на сервере лежит версия, где чужие руки поправили и повторы, и тот же вес — причём позже. Прогноз: что получится каждым правилом и в чём они сойдутся?
Сошлись в главном: оба сохранили чужие повторы 12 вместе со своим весом — это и есть слияние по полям, ни одна правка не пропала целиком. Разошлись в спорном поле. По _changed победил местный вес 82.5, хотя сделан он раньше чужого: про время это правило не спрашивает вовсе. По отметкам победила более поздняя правка, 85.
Правильного ответа тут нет: «моё побеждает» — осмысленный выбор, пока активное устройство одно, и он заметно дешевле, потому что отметку на каждое поле хранить не надо. Важно другое — где это правило живёт. У нас оно общее для телефона и сервера, поэтому итог у всех один. У WatermelonDB оно клиентское: сервер получает целую строку и решает чем угодно. Значит, «одно правило для обеих сторон» вы всё равно пишете руками, просто теперь только на своей стороне сервера.
Место, куда своё правило вписать на клиенте, у библиотеки есть — conflictResolver из тех же типов: (table, local, remote, resolved) => DirtyRaw. Рядом предупреждение авторов: это «Advanced (unsafe) customization point», зовётся он для каждой обновляемой записи, «so be sure that this function is FAST».
Ещё одна деталь, в которой видно нашу очередь: после удачной отправки WatermelonDB ставит записям _status: "synced" и _changed: "" — то есть «что менял» помнится ровно до подтверждения, как у нас операция лежит в очереди до вердикта. Разница в том, что у нас вердикт приходит по имени операции, а тут подтверждение молчаливое: пачка прошла целиком.
PowerSync подходит с другой стороны. У него есть настоящая очередь изменений, и она отдана вам в руки: getCrudBatch(limit?) — пачка записей, getNextCrudTransaction() — ровно одна транзакция целиком, и у обеих complete(), который убирает отправленное из очереди. В типах это сказано прямым текстом: «Once the data have been successfully uploaded, call CrudBatch.complete before requesting the next batch». Это наш цикл слово в слово: пачка уходит, очередь пустеет только по подтверждению.
Единица очереди — CrudEntry: clientId, id строки, table, op и opData. У op три значения, и описаны они в CrudEntry.d.ts так: PUT — «Insert or replace existing row. All non-null columns are included in the data», PATCH — «Contains the id, and value of each changed column», DELETE — «Contains the id». Средняя строка и есть наша операция: имя строки плюс изменённые поля.
Наверх при этом пишете вы сами. Коннектор — два метода:
Переведём в этот формат ту же очередь, что и в прошлый раз: правка старого подхода, новый подход и удаление. Прогноз: что окажется в opData у правки, чем станет надгробие и с каких чисел пойдёт clientId?
Правка уехала как PATCH с единственным полем weightKg — то самое, за что глава держалась: слияние по полям на той стороне остаётся возможным. Надгробие стало DELETE с одним идентификатором: удаление здесь служебное, как у WatermelonDB, а надо ли вам своё поле deleted со сроком хранения, решает ваш сервер — «Удаление — это запись». А clientId — авто-инкремент на устройстве: он продолжил счёт с трёх, а не начался заново вместе с пачкой. Внутри телефона он уникален, между телефонами — нет, поэтому ключ, по которому ваш сервер узнаёт повтор, складывается ровно как в главе 6: имя устройства плюс этот номер.
| что в «Подходе» | что в PowerSync |
|---|---|
| исходящая очередь журнала | getCrudBatch(limit?) и getNextCrudTransaction() |
| «убрать из очереди по вердикту» | complete() после удачной отправки |
sync.push на ваш сервер | uploadData в коннекторе: ваш код, ваш бэкенд |
sync.pull с курсором | поток вниз от сервиса PowerSync, курсора вы не видите |
| индикатор «ждут отправки: N» | getUploadQueueStats() → { count, size } и useStatus() |
Цена — нативная. На React Native PowerSync работает поверх @op-engineering/op-sqlite, и в package.json он записан жёстко: >=17.1.0 <19.0.0. Это нативный модуль, которого в готовом клиенте Expo Go нет, — значит, свой dev client, «Где кончается готовый клиент: свой dev client». В README пакета так и написано: ставится npx expo install @powersync/react-native @op-engineering/op-sqlite, запускается npx expo run:ios, а Expo-проектам вдобавок нужны полифил @azure/core-asynciterator-polyfill и плагин @babel/plugin-transform-async-generator-functions, иначе живые запросы и getCrudTransactions() не заработают.
Две половины синхронизации здесь разъехались по ответственности, и понимать это лучше до выбора: вниз, к телефону, изменения идут от сервиса PowerSync, которому fetchCredentials выдаёт токен; вверх, от телефона, — вашим uploadData на ваш бэкенд. Сервер из части VI никуда не девается, сервис добавляется сверх него.
Теперь про то, чем движок синхронизации не является, — потому что путают их постоянно, и в мобильном проекте роль «того, что у нас вместо синхронизации» обычно достаётся TanStack Query. Вот как он настроен в настоящем приложении автора, и настроен правильно:
Каждая строка тут — про копию. staleTime: 0: данные устарели сразу, потому что оригинал на сервере. gcTime в полчаса: копию можно выбросить, она восстановима. refetchOnWindowFocus: вернулись — сходим за свежим (фокус окна в React Native приходится сообщать руками из AppState, «Свернули и вернулись»). И clear() при UNAUTHORIZED обязателен: иначе первый кадр после входа другим человеком покажет чужие данные.
В local-first та же самая уборка стирает единственный экземпляр тренировки: прогон в статье «Свернули и вернулись» напечатал «на экране 0 | ждут отправки 0 | у сервера строк 0». Дело не в качестве библиотеки, а в том, чем она владеет:
| кеш запросов | движок синхронизации | |
|---|---|---|
| что лежит внутри | копия серверного состояния | оригинал и очередь неотправленного |
| срок годности | staleTime и gcTime | нет: выбрасывать нечего |
| при выходе из аккаунта | clear() — и правильно | пауза, данные ждут входа |
| без сети | состояние ошибки на экране | обычная работа, растёт очередь |
| кто решает спор | сервер: он источник правды | правило слияния, общее для сторон |
| что показывает экран | три состояния запроса | индикатор очереди: «ждут отправки: N» |
Лишним кеш от этого не становится. Как только у «Подхода» появится что-то не ваше — общий справочник упражнений с сервера, чужие программы тренировок, — это будут данные, оригинал которых на сервере, и держать их надо именно кешем со сроком годности. Кеш изнутри мы собирали в части IV, в статье «Свой кеш: ключ из входа, метка вместо списка адресов»: запись лежит под ключом из входа, по метке её выбрасывают, и следующее чтение считает её заново. Кеш там серверный, у Next, а не в телефоне, но и он — восстановимая копия. Дневник в этот список не входит: его оригинал в телефоне.
Проверка на один вопрос: что случится с этими данными, если телефон уронить в реку? Если ответ «ничего, они на сервере» — это кеш, и чистить его можно когда угодно. Если «пропадёт всё, что не успело уехать» — это оригинал, и кешем управлять им нельзя.
Соберём всё вместе. Колонки в таблице — это не «лучше или хуже», а «что придётся дописать самому»:
| свой протокол | WatermelonDB 0.28.0 | PowerSync 2.3.0 | |
|---|---|---|---|
| что едет по сети | операция с изменёнными полями | запись целиком | PUT, PATCH, DELETE |
| имя для повтора | opId с телефона | нет, только id записи | clientId устройства |
| слияние | по отметкам [at, opId], одно на обе стороны | по _changed, только на клиенте | ваше: данные приходят вам целиком |
| удаление | надгробие полем | ключ deleted | DELETE по id |
| курсор | номер сервера | lastPulledAt, время сервера | у сервиса PowerSync |
| отказ по операции | вердикт с причиной | experimentalRejectedIds | ваш ответ внутри uploadData |
| живой снимок | useSyncExternalStore | withObservables, useDatabase | useQuery, useStatus |
| нативный код | нет, хватает expo-sqlite | свой модуль автолинком | @op-engineering/op-sqlite |
| чей сервер | ваш | ваш | ваш плюс сервис PowerSync |
Строка «живой снимок» стоит отдельного взгляда: это единственное место, где движок вообще трогает разметку.
Ни один из трёх вариантов не меняет того, что вы знаете про React: это подписка на внешний источник, просто отданная библиотеке. Свой хук на useSyncExternalStore объяснён в части IV («Свой хук: логика без разметки»), и остальные два делают ровно то же — withObservables через наблюдаемый запрос, useQuery через живой SQL. Ни спиннера, ни трёх состояний запроса тут по-прежнему нет: данные на устройстве.
И две честные оговорки напоследок. Первая — про WatermelonDB: последний стабильный выпуск 0.28.0 вышел 7 апреля 2025 года и на момент написания остаётся последним, а после 11 августа 2025 года в основной ветке репозитория не было ни одного коммита. Законченный это пакет или брошенный, по датам не решить — как их отличают, разобрано в статье «Живой ли пакет». Поля peerDependencies в пакете нет вовсе, то есть диапазон совместимых версий React Native им не заявлен; линкуется он автолинком (react-native.config.js, папка native/android, свой podspec), а значит, это тоже нативный модуль и тоже свой dev client. Совместимость с React Native 0.86 проверяется на устройстве, а не по числам в package.json.
Вторая — про то, чего не отменяет ни один движок: горизонт курсора, срок жизни надгробий, размер памяти вердиктов, снимок для нового телефона. Всё это просто переезжает на вашу сторону сервера — в часть VI, где на место миниатюры встанет настоящий сервер с теми же именами sync.push и sync.pull. Зато их документация теперь читается как перевод знакомого: pullChanges — это pull с курсором, pushChanges — push с вердиктами, getCrudBatch — исходящая очередь, complete() — «убрать по вердикту», markAsDeleted — надгробие.
Чем бы вы ни синхронизировались, проверять это надо теми же двумя рубильниками: «сети нет» и «сервер всё сделал, а ответ потерялся». Если в документации движка про повтор пачки не сказано ничего, значит, его просто не проверяли, — и проверять придётся вам.
- Движков два вида: по состоянию (WatermelonDB) и по операциям (PowerSync). Всё остальное — следствия этого различия.
- У WatermelonDB журнала нет:
_statusи_changedживут в самой записи, аchanges— это{ created, updated, deleted }, где у удалённых одни идентификаторы. В прогоне правка веса уехала целой строкой, аopIdиatвchangesне нашлось: идемпотентность и слияние по отметкам придётся строить снаружи. - Слияние по полям у него всё-таки есть, но правило другое и клиентское — «все локально изменённые колонки побеждают». В прогоне местный вес 82.5 выиграл у более позднего чужого 85, а чужие повторы 12 сохранили оба правила.
- Очередь CRUD у PowerSync — это ваша исходящая очередь:
getCrudBatchиgetNextCrudTransaction, аcomplete()убирает отправленное.PATCHнесёт только изменённые поля,DELETE— один id,clientId— счётчик устройства, поэтому ключ повтора на сервере складывается как в главе 6. - Нативный код нужен обоим: op-sqlite с жёстким диапазоном
>=17.1.0 <19.0.0и автолинк WatermelonDB, у которого peerDependencies нет вовсе, а последний выпуск — апрель 2025. Готового клиента Expo Go не хватит ни там, ни там. - Кеш запросов движком не работает: он держит копию со сроком годности, и
clear()при 401 для него правилен. В local-first та же уборка стирает единственный экземпляр тренировки, поэтому кешем остаются только данные, оригинал которых не в телефоне.
1/4Человек поправил в записи одно поле, и WatermelonDB отправляет её в pushChanges. Что окажется в updated?
2/4Чего нет в changes WatermelonDB и что из-за этого приходится строить снаружи?
3/4uploadData успешно отправил пачку на ваш сервер и не позвал complete(). Что будет дальше?
4/4Чем движок синхронизации отличается от кеша запросов вроде TanStack Query?
Надгробие, уехавшее правкой
Переходник из журнала в формат WatermelonDB написан почти правильно: новые записи идут в created, правленые — в updated. Почти — потому что удаление у нас обычное поле строки, и переходник видит его как обычную правку. Телефон записал два подхода и один из них удалил. Посмотрите, что получит другая сторона, и почините переходник.
В этом формате надгробие — не правка, а отдельный ключ: строка уходит в deleted одним идентификатором и в updated не попадает вовсе.
Вывод становится «в deleted: ["A-2"]» и «на другой стороне строк: 1 ["A-1"]».
Без этого удалённый подход уезжает обычной записью, у которой есть лишняя колонка deleted. Схемы под неё на той стороне нет, читать её никто не станет — и подход останется жить у всех, кроме телефона, где его удалили. Это ровно та беда, с которой начиналась статья «Удаление — это запись», только устроил её теперь переходник.
Очередь, которую никто не очищает
Коннектор PowerSync устроен просто: uploadData берёт пачку, отправляет её на ваш сервер и сообщает очереди, что пачка уехала. Последнего шага здесь нет. Очередь — миниатюра (в настоящем PowerSync complete() возвращает промис и его ждут через await), но ведёт себя так же. Посмотрите на вывод и допишите одну строку.
Очередь пустеет только от complete():
Вывод становится «отправлено: [1,2,3]» и «в очереди осталось: 0».
Правило то же, что в нашем цикле: из очереди уходит только то, на что пришёл вердикт («Цикл синхронизации: отправить, забрать, не задвоить»). Разница в том, что вердикт здесь выносите вы сами — успешной отправкой на свой сервер, — и потому же complete() нельзя звать заранее: пачка, потерянная по дороге, должна остаться в очереди.
PATCH, отправленный целой строкой
Переходник в очередь CRUD кладёт в opData всю строку с телефона — так проще, и пока устройство одно, разницы не видно. Дома в этом же подходе поправили повторы, и та правка уже на сервере. Посмотрите, что с ней станет, и почините opData.
PATCH по определению несёт «the id, and value of each changed column», а изменённые поля лежат в самой операции:
Вывод становится «в opData уехало: {"weightKg":82.5}» и «на сервере стало: {"id":"A-1","exercise":"жим","weightKg":82.5,"reps":12}».
Целой строкой уезжает не только правка веса, но и всё, что телефон знал в момент записи, — включая повторы, которых он ещё не видел. Это та же потеря, из-за которой модель «по состоянию» требует слияния снаружи: чем крупнее единица, тем больше чужой работы уносит победитель.