Готовые движки: WatermelonDB и PowerSync

синхронизация≈ 17 минут

Здесь будет встроен видео-разбор темы (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». Это наше надгробие из статьи «Удаление — это запись», только служебное и спрятанное от вас.

Синхронизация здесь — одна функция, и вся её форма видна в типах:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Узнаваемого много. pullChanges — это sync.pull, только курсор у него не номер сервера, а время: lastPulledAt приходит из прошлого ответа, новое сервер возвращает в timestamp. Чем опасно время вместо номера, глава разобрала в первой же статье, «Сервер — партнёр, а не хозяин»; спасает тут ровно одно — что время это серверное, одно на всех, а не показания часов телефона. pushChanges — это sync.push, но ответ у него куда беднее: experimentalRejectedIds, список отвергнутых идентификаторов записей по таблицам. Ни причины, ни вердикта по каждой операции — потому что операций здесь нет вовсе, и слово «experimental» в имени стоит не случайно.

Переведём журнал «Подхода» в этот формат. Телефон записал два подхода и синхронизировался; потом, уже без сети, поправил вес в первом, добавил третий и удалил второй. Переходнику достаются журнал, исходящая очередь и хранилище. Прогноз: в какой из трёх ключей попадёт каждая строка, что окажется внутри updated — одно поле или целая строка — и найдутся ли в changes имя операции и отметка времени?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Три строки разошлись по трём ключам ровно так, как ждёт 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, ложатся поверх пришедшей версии. Проверим на одном случае: на телефоне поправили вес, а на сервере лежит версия, где чужие руки поправили и повторы, и тот же вес — причём позже. Прогноз: что получится каждым правилом и в чём они сойдутся?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Сошлись в главном: оба сохранили чужие повторы 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». Средняя строка и есть наша операция: имя строки плюс изменённые поля.

Наверх при этом пишете вы сами. Коннектор — два метода:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Переведём в этот формат ту же очередь, что и в прошлый раз: правка старого подхода, новый подход и удаление. Прогноз: что окажется в opData у правки, чем станет надгробие и с каких чисел пойдёт clientId?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Правка уехала как 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. Вот как он настроен в настоящем приложении автора, и настроен правильно:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Каждая строка тут — про копию. staleTime: 0: данные устарели сразу, потому что оригинал на сервере. gcTime в полчаса: копию можно выбросить, она восстановима. refetchOnWindowFocus: вернулись — сходим за свежим (фокус окна в React Native приходится сообщать руками из AppState, «Свернули и вернулись»). И clear() при UNAUTHORIZED обязателен: иначе первый кадр после входа другим человеком покажет чужие данные.

В local-first та же самая уборка стирает единственный экземпляр тренировки: прогон в статье «Свернули и вернулись» напечатал «на экране 0 | ждут отправки 0 | у сервера строк 0». Дело не в качестве библиотеки, а в том, чем она владеет:

кеш запросовдвижок синхронизации
что лежит внутрикопия серверного состоянияоригинал и очередь неотправленного
срок годностиstaleTime и gcTimeнет: выбрасывать нечего
при выходе из аккаунтаclear() — и правильнопауза, данные ждут входа
без сетисостояние ошибки на экранеобычная работа, растёт очередь
кто решает спорсервер: он источник правдыправило слияния, общее для сторон
что показывает экрантри состояния запросаиндикатор очереди: «ждут отправки: N»

Лишним кеш от этого не становится. Как только у «Подхода» появится что-то не ваше — общий справочник упражнений с сервера, чужие программы тренировок, — это будут данные, оригинал которых на сервере, и держать их надо именно кешем со сроком годности. Кеш изнутри мы собирали в части IV, в статье «Свой кеш: ключ из входа, метка вместо списка адресов»: запись лежит под ключом из входа, по метке её выбрасывают, и следующее чтение считает её заново. Кеш там серверный, у Next, а не в телефоне, но и он — восстановимая копия. Дневник в этот список не входит: его оригинал в телефоне.

Проверка на один вопрос: что случится с этими данными, если телефон уронить в реку? Если ответ «ничего, они на сервере» — это кеш, и чистить его можно когда угодно. Если «пропадёт всё, что не успело уехать» — это оригинал, и кешем управлять им нельзя.

Соберём всё вместе. Колонки в таблице — это не «лучше или хуже», а «что придётся дописать самому»:

свой протоколWatermelonDB 0.28.0PowerSync 2.3.0
что едет по сетиоперация с изменёнными полямизапись целикомPUT, PATCH, DELETE
имя для повтораopId с телефонанет, только id записиclientId устройства
слияниепо отметкам [at, opId], одно на обе стороныпо _changed, только на клиентеваше: данные приходят вам целиком
удалениенадгробие полемключ deletedDELETE по id
курсорномер сервераlastPulledAt, время серверау сервиса PowerSync
отказ по операциивердикт с причинойexperimentalRejectedIdsваш ответ внутри uploadData
живой снимокuseSyncExternalStorewithObservables, useDatabaseuseQuery, useStatus
нативный коднет, хватает expo-sqliteсвой модуль автолинком@op-engineering/op-sqlite
чей сервервашвашваш плюс сервис PowerSync

Строка «живой снимок» стоит отдельного взгляда: это единственное место, где движок вообще трогает разметку.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Ни один из трёх вариантов не меняет того, что вы знаете про 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. Почти — потому что удаление у нас обычное поле строки, и переходник видит его как обычную правку. Телефон записал два подхода и один из них удалил. Посмотрите, что получит другая сторона, и почините переходник.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

В этом формате надгробие — не правка, а отдельный ключ: строка уходит в deleted одним идентификатором и в updated не попадает вовсе.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится «в deleted: ["A-2"]» и «на другой стороне строк: 1 ["A-1"]».

Без этого удалённый подход уезжает обычной записью, у которой есть лишняя колонка deleted. Схемы под неё на той стороне нет, читать её никто не станет — и подход останется жить у всех, кроме телефона, где его удалили. Это ровно та беда, с которой начиналась статья «Удаление — это запись», только устроил её теперь переходник.

Очередь, которую никто не очищает

Коннектор PowerSync устроен просто: uploadData берёт пачку, отправляет её на ваш сервер и сообщает очереди, что пачка уехала. Последнего шага здесь нет. Очередь — миниатюра (в настоящем PowerSync complete() возвращает промис и его ждут через await), но ведёт себя так же. Посмотрите на вывод и допишите одну строку.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Очередь пустеет только от complete():

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится «отправлено: [1,2,3]» и «в очереди осталось: 0».

Правило то же, что в нашем цикле: из очереди уходит только то, на что пришёл вердикт («Цикл синхронизации: отправить, забрать, не задвоить»). Разница в том, что вердикт здесь выносите вы сами — успешной отправкой на свой сервер, — и потому же complete() нельзя звать заранее: пачка, потерянная по дороге, должна остаться в очереди.

PATCH, отправленный целой строкой

Переходник в очередь CRUD кладёт в opData всю строку с телефона — так проще, и пока устройство одно, разницы не видно. Дома в этом же подходе поправили повторы, и та правка уже на сервере. Посмотрите, что с ней станет, и почините opData.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

PATCH по определению несёт «the id, and value of each changed column», а изменённые поля лежат в самой операции:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится «в opData уехало: {"weightKg":82.5}» и «на сервере стало: {"id":"A-1","exercise":"жим","weightKg":82.5,"reps":12}».

Целой строкой уезжает не только правка веса, но и всё, что телефон знал в момент записи, — включая повторы, которых он ещё не видел. Это та же потеря, из-за которой модель «по состоянию» требует слияния снаружи: чем крупнее единица, тем больше чужой работы уносит победитель.