«Подход»: что в подложке было ненастоящим

проект≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

«Подход» собран целиком: справочник со своим окном, экраны со стеком и историей, дневник, который пишется в зале без сети, журнал операций, сервер-партнёр, надгробия, правило слияния, свайп по строке и кольцо таймера. Всё это лежит в подложке и работает в предпросмотре — и в этом опасность последней главы проекта: работающий пример легко принять за работающее приложение. Между ними список подмен. Часть объявил сам предпросмотр ещё в первой главе, «Что в этой песочнице настоящее»; часть мы завели сами в главах 6 и 7, когда понадобились имена, часы и сервер. Эта статья проходит по проекту, составляет опись швов и раскладывает её на три полки: что чинится кодом клиента, что проверяется только на телефоне и что не закрыть ни тем, ни другим — это работа сервера части VI. В конце — приёмка на двух устройствах: один вечер из жизни дневника, который стоит прогонять после каждой правки синхронизации.

Разделы темы:·

Первая глава очертила границу платформы: Platform.OS отвечает "web", пакеты Expo не собираются вовсе, а типы react-native шире любого рантайма. Те подмены были чужие — их сделал предпросмотр. Дальше появились свои: в главе 6 имя строке начал выдавать счётчик, время — другой счётчик, а в главе 7 сервером стала функция в этой же памяти. Каждую мы вводили сознательно и с объяснением, но лежат они теперь ровно там же, где настоящий код, и ничем себя не выдают: всё работает.

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

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
Имя строки — A-1, имена операций — A:1 и A:2: устройство названо буквой, остальное счётчик. Отметки времени — 1 и 2, то есть такты, а не миллисекунды. pull ответил тем же выражением, в котором его позвали: сервер лежит в этой же памяти, и ни промиса, ни await здесь нет. «Перезапуск» дал пустой журнал — другого места, кроме памяти кадра, у него нет. А удаление оставило у сервера строку и третью запись в журнале: надгробие, которое здесь не выбросит никто и никогда.

Синхронный push — не мелочь оформления. В приложении между отправкой пачки и вердиктом проходят десятки и сотни миллисекунд, и в этот промежуток человек успевает записать ещё подход или поправить тот же самый. Изобразим промежуток таймером и посмотрим, что станет с очередью.

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

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

подменачто в предпросмотречто на телефоне
сервер синхронизацииминиатюра в этой же памяти: push и pull отвечают тем же выражениемсеть: await, задержки, потерянные ответы, 401
журнал операциймассив в памяти кадра, перезапуск начинает с нулятаблицы sets и ops в expo-sqlite, транзакция на каждую запись
имя устройствабуква и счётчик: A-1, A:2randomUUID() из expo-crypto один раз при первом запуске
время операциисчётчик тактов: at равен 1, 2, 3гибридные часы поверх Date.now()
надгробиякопятся и живут вечно: выбрасывать их некомусрок — за сервером, и в части VI он решает надгробия не выбрасывать вовсе

Подмена опасна не тем, что она подмена, а тем, что про неё забывают. Счётчик тактов честно назван счётчиком в статье про часы — и он же тихо держит половину эталонов этой части: поставьте на его место Date.now(), и любой эталон, где напечатано «at 1», станет неправдой.

Три подмены из пяти закрываются кодом клиента, и ни одна не трогает протокол. Имя строки, диск под журналом и часы — это источники, а правила остались прежними: имя должно быть уникальным, время — монотонным и сравнимым, диск — переживать перезапуск.

Проверим это прямо. Один и тот же случай — в зале правят вес, дома повторы того же подхода — прогоним дважды: сначала со счётчиками песочницы, потом с именами вида 3f2a91c4 и часами, начинающимися с 1 768 000 000 000. Прогноз: изменится ли итог слияния и сойдутся ли устройства во втором прогоне?

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

Поля совпали до знака: вес из зала и повторы из дома пережили оба прогона, устройства сошлись и там, и там. Отличается ровно одно — имя строки, потому что оно составлено из имени устройства. Значит, подмена была законной: от имени протокол просит уникальности, от at — монотонности и сравнимости, и ни одна строка правила из «Кто прав, если оба правы» не спрашивает, откуда взялось число.

Место подмены стоит собрать в одном файле — тогда переход на телефон не разбредается по коду. Приём тот же, что в статье «Одно приложение, три платформы»: рядом лежат два файла, а выбирает между ними сборщик.

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

Журнал и токен при этом разъезжаются по разным хранилищам: журнал — в expo-sqlite («expo-sqlite: настоящая база в телефоне»), токен синхронизации — в SecureStore, потому что это секрет, а не данные. Выгрузка дневника в файл, которая тоже упирается в настоящие пакеты, — отдельная статья этой главы, «Дневник принадлежит вам».

Проверьте свои приёмки до перехода, а не после: эталон, в котором записано «id»: «A-1», станет красным не потому, что приложение сломалось, а потому, что имя устройства стало настоящим. Это первая задача в конце статьи.

Вторая полка — подмены предпросмотра, и кодом они не чинятся вовсе: чинить нечего, это просто другая среда. Всё, что в таблице, проверено по исходникам react-native-web 0.21.2 — той самой сборки, которая выполняет примеры этой части.

что подмененочто в предпросмотречем проверить на телефоне
нажатиеfireEvent.press — это click; hitSlop и pressRetentionOffset веб-версия Pressable не разбирает вовсепалец: срыв нажатия, промах мимо мелкой кнопки, длинное нажатие («Нажатие — это автомат, а не onClick»)
Alert.alertв исходнике class Alert { static alert() {} } — пустой метод, диалога нетсистемный диалог и его кнопки; свой — из статьи «Modal и свой Alert вместо пустышки»
безопасные зоныSafeAreaView ставит отступы из env(safe-area-inset-*), а в рамке примера они нулевыевырез, домашняя полоска и поворот экрана
клавиатурау KeyboardAvoidingView тело обработчика пустое: onKeyboardChange(event) {}клавиатура, закрывающая поле веса на экране тренировки
hairlineWidthвсегда 1: в исходнике рядом сказано, что дробный пиксель браузер округлил бы в нольтонкая линия на экране с плотностью 2 и 3
жизнь процесса«убить процесс» — это вызов функции в примересистема, выгружающая приложение из фона
диск и секретыпамять кадра: localStorage — заглушка, IndexedDB и Cache бросают SecurityErrorexpo-sqlite и SecureStore на устройстве

Ни одна строка таблицы не про ошибку React Native: это веб-сборка, которая делает вид, что она телефон. Предпросмотр при этом честнее, чем кажется, — он ещё и рисует то, чего телефон не покажет вовсе, как строку прямо внутри View («Ни div, ни класса, ни каскада»). Виды оговорок разобраны в описи песочницы из первой главы, и вся эта таблица — их пересчёт на собранный проект.

Проверка на устройстве — не «посмотреть, как выглядит». Это опыты с выключателями, и первые два делаются одной строкой в терминале:

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

Оба опыта проверяют главное свойство local-first: подход, записанный в зале, обязан пережить смерть процесса, потому что второй копии у него нет. К ним добавляется авиарежим — единственный способ увидеть настоящую потерю сети вместо нашего рубильника net.offline: записать без сети, убить процесс, открыть снова, включить сеть и убедиться, что подход уехал сам.

Список короткий не потому, что предпросмотр хорош, а потому, что отличия называли поимённо десять глав подряд. Собранный вместе, он и есть план первого вечера с телефоном в руке.

Третья полка — вопросы, на которые у телефона нет и не может быть данных. Их три, и все они про других: горизонт курсора (кто из устройств что уже видел), срок надгробий (когда их можно выбросить) и снимок дневника для нового телефона (что отдать тому, у кого курсор нулевой).

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

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

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

Снимок вместо истории умеет отдать только сервер: у него готовые строки, а у нас журнал. То же с надгробиями — чтобы выбросить надгробие, надо знать, что его увидели все устройства, а телефон знает ровно один курсор, свой. В «Удаление — это запись» чистка по сроку воскресила подход на устройстве, пришедшем позже; починить это на клиенте нечем.

вопроспочему не решается на телефонекто отвечает в части VI
горизонт курсораустройство знает только свой курсорсервер, которому горизонт не понадобится: он не выбрасывает надгробий
срок надгробийвыбрасывать можно только то, что увидели всерешение сервера: не выбрасывать — цена и прогон в части VI
снимок для нового телефонау клиента есть журнал, а нужны строкивыдача строк целиком в sync.pull

Шов между частями проходит по именам: sync.push и sync.pull названы так же, как процедуры настоящего API. Когда в части VI на место миниатюры встанет сервер на Node, экраны «Подхода» не изменятся ни строкой — поменяется только то, что стоит за двумя вызовами.

Опись составлена; теперь проверка, которую стоит гонять после каждой правки синхронизации. Это не юнит-тест, а один вечер из жизни дневника, записанный кодом: зал без сети, дорога домой, правка на вебе, удаление лишнего — и сверка итога.

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

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

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

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

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

Держите приёмку рядом с кодом синхронизации, а не в планах «когда-нибудь напишем тесты»: она краснеет ровно в тот день, когда кто-то меняет правило слияния, — и это единственный день, когда об этом полезно узнать.

  • Проект собран и работает — в этом и опасность. Подмен в нём пять: сервер в той же памяти, журнал в памяти кадра, имя-счётчик, время-счётчик и надгробия, которые здесь никто не чистит.
  • Синхронный push прячет целый класс ошибок: в приложении между отправкой и вердиктом проходит время, и очередь за него меняется — в прогоне из одной операции стало две. Поэтому вердикт адресован операции, а не пачке.
  • Три подмены чинятся кодом клиента: имя устройства — randomUUID() из expo-crypto, диск — expo-sqlite, время — гибридные часы поверх Date.now(). Протокол при этом не меняется: от имени он просит уникальности, от отметки — монотонности и сравнимости.
  • Вторая полка кодом не чинится: fireEvent.press вместо пальца, пустой Alert.alert, нулевые безопасные зоны, пустой onKeyboardChange, hairlineWidth, равный единице, смерть процесса и настоящий диск. Это проверяется выключателями на устройстве: убить процесс, «не сохранять действия», авиарежим.
  • Третья полка ждёт сервера части VI: горизонт курсора, срок надгробий и снимок для нового телефона. У клиента нет данных, чтобы ответить, — он знает один курсор, свой, а pull отдаёт историю: девять операций ради трёх строк.
  • Приёмка на двух устройствах — вечер из жизни дневника, записанный кодом: запись без сети, доставка по вердиктам, слияние по полям, надгробие и сходимость. Сверять надо устройства между собой и обязательно давать круг обоим: одного sync() в конце мало.

1/4В песочнице server.push(batch) отдаёт вердикты тем же выражением, в котором его позвали. Какую ошибку из-за этого нельзя поймать в предпросмотре?

2/4Что из перечисленного закрывается кодом клиента, а не ждёт сервера части VI?

3/4Новый телефон синхронизируется с нуля. Что он получит от pull?

4/4Почему приёмке «два устройства» мало одного вызова sync() в конце?

Приёмка, которая проверяла не то

Эта приёмка синхронизирует телефон и сверяет его с тем, что лежит у сервера. Вечер обычный: подход записан на телефоне, веб-версия поправила повторы, телефон в это же время — вес того же подхода. Посмотрите, что печатают три строки, и почините проверку так, чтобы она говорила правду.

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

Сверять телефон с сервером бессмысленно: телефон только что сам отправил туда свою правку — конечно, они совпадут. Приёмка обязана сравнивать устройства между собой, а перед сравнением дать круг обоим:

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

Тогда вывод становится честным: у обоих weightKg 82.5 и reps 6, и приёмка говорит «сошлось»: true.

Третий вызов не лишний. С двумя — web.sync(); phone.sync(); — прогон печатает веб: … "weightKg":80 … и сошлось: false: телефон успел забрать чужие повторы, а его собственная правка веса уехала на сервер тем же вызовом и до веб-версии ещё не добралась. pull приносит только то, что уже отправлено.

Сломанная приёмка хуже отсутствующей: она зелёная при разошедшихся устройствах, а расхождение дневников — ровно та беда, ради которой всю главу 7 писали протокол.

Эталон, который держится на имени устройства

Эта проверка сверяет дневник с эталоном, записанным руками, и она зелёная — ровно до того дня, когда имя устройства станет настоящим: randomUUID() из expo-crypto вместо буквы A. Запустите и почините эталон так, чтобы «совпало» печатали обе строки.

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

Имя строки составлено из имени устройства, поэтому эталон должен брать его оттуда же, откуда берёт приложение, — у журнала:

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

Обе строки печатают «совпало». Годится и второй способ — сверять поля, а имя не сверять вовсе: const { id, ...fields } = row;, как в примере про счётчики и uuid.

Правило общее: в эталоне не место тому, что подменено. В этой части подменены и имя, и время, и приёмка, которая запомнила A-1 или at: 1, краснеет не от поломки приложения, а от перехода на настоящий телефон — в самый неподходящий день.