Журнал: имена и номера рождаются на телефоне

данные≈ 12 минут

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

В прошлой статье хранилище принимало строки с готовыми id — «s1», «s2» — и не спрашивало, откуда они берутся. У заглушки «сервер главный» номер выдавал сервер в ответе на запрос. В зале ответа нет, а имя строке нужно в момент нажатия: подход ссылается на тренировку, которую создали здесь же минуту назад. Эта статья отдаёт рождение имён телефону и меняет то, что помнит хранилище: не последнюю версию строки, а журнал изменений — операций, у каждой из которых своё имя. Строка становится свёрткой журнала, а неотправленные операции — исходящей очередью, которую в главе 7 заберёт синхронизация. Почти всё здесь — ваш собственный JavaScript. С платформой статья встречается в одном месте — у crypto: в песочнице он есть, на телефоне его нет, и заменяет его пакет expo-crypto, то есть уже этаж Expo.

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

Подход в «Подходе» — не отдельная запись: у него есть поле workoutId, ссылка на тренировку. Тренировку человек создаёт прямо в зале — «Ноги, 11 сентября» — и тут же записывает в неё первый подход. Если номера выдаёт сервер, как у addSet в заглушке из прошлой статьи, то у тренировки нет номера, пока сервер не ответил. А сервер не ответит до выхода из зала, и подходу не на что сослаться.

Обходной путь известен — временный id. Тренировка живёт как tmp-1, а когда сервер ответит «417», приложение переписывает tmp-1 на 417 везде, где он успел оказаться: в workoutId каждого подхода, в операциях, которые ждут отправки, в адресе /workout/tmp-1 в истории стека из главы про навигацию, в ключе строки списка. Последнее видно глазами: keyExtractor из статьи про FlatList вернёт другой ключ, React сочтёт строку новой и смонтирует её заново, сбросив её состояние, — механизм разобран в «Своей сверке» части IV. Любое пропущенное место остаётся ссылкой в никуда. А если ответ сервера потеряется по дороге, повтор создаст тренировку второй раз — этот случай разберёт глава 7.

Проще, чтобы имя строки рождалось там же, где строка: на телефоне, в момент нажатия. Тогда подход ссылается на тренировку сразу, и ссылка остаётся верной навсегда — после синхронизации id тот же, переписывать нечего. Первое, что приходит в голову, — свой счётчик: 1, 2, 3. Проверим его на двух устройствах: телефон в зале без сети и веб-версия дома создают по тренировке, а потом копии сходятся, и строка с тем же id считается той же самой строкой. Второй прогон — тот же, но к номеру приписано имя устройства. Прогноз: что останется после слияния в каждом прогоне?

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

Простой счётчик выдал обоим устройствам «1», и при слиянии «спина» с веба затёрла «ноги» с телефона: тренировка пропала целиком. Хуже того, подходы телефона ссылались на тренировку «1» и теперь молча висят на «спине» — ошибки нет, данные просто неверны. Имя должно быть уникальным не на устройстве, а везде. Приставка устройства делает его таким: счётчики A и B могут совпадать сколько угодно, а «A-1» и «B-1» не совпадут никогда. Условие одно — у двух устройств не бывает одной приставки.

Имя строки рождается там же, где строка. Всё остальное — переписывание ссылок задним числом.

«A» и «B» в примерах — ради читаемости. Настоящая приставка должна быть уникальной среди всех устройств всех пользователей, а договориться об этом не с кем: сети нет. Поэтому имя устройства — случайное число такой длины, что совпадения на практике не бывает: UUID версии 4, 122 случайных бита. Создаётся оно один раз, при первом запуске, и дальше хранится рядом с журналом. В браузере для этого есть crypto.randomUUID(). Прогноз: что скажет компилятор и что напечатает пример?

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

Компилятор пишет «Не удается найти имя "crypto".», а пример всё равно выполняется: тысяча id по 36 символов, все разные. Правы оба, и в этом весь смысл примера. Выполняет код браузер — песочница учебника, а в браузере crypto есть. Проверяет же компилятор по типам набора rn, где нет библиотеки DOM, — и глобального crypto там нет по той же причине, по какой его нет на телефоне. React Native 0.86 при старте ставит свои глобали — fetch, XMLHttpRequest, FormData, URL, Blob, WebSocket, AbortController (Libraries/Core/setUpXHR.js). Expo SDK 57 добавляет TextDecoder, structuredClone и свой fetch (expo/src/winter/runtime.native.ts). crypto нет ни в одном из списков, и на телефоне та же строка упадёт с ReferenceError.

Это обратная сторона ловушки из статьи «Что в этой песочнице настоящее». Там типы были шире рантайма предпросмотра: PermissionsAndroid компилировался и падал. Здесь наоборот — рантайм предпросмотра шире телефона, и правду о телефоне говорит компилятор. Пример печатает длину и число уникальных, а не сами id: они новые при каждом запуске, и сверять вывод было бы не с чем. Цифра 4 на пятнадцатой позиции — номер версии UUID; у четвёртой версии всё, кроме нескольких служебных бит, случайно.

На телефоне тот же UUID даёт randomUUID() из пакета expo-crypto. Ставится он как любой модуль Expo, а в коде меняется только импорт:

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

Внутри пакета — две вещи, знакомые по статье «Одно приложение, три платформы». randomUUID зовёт нативный модуль ExpoCrypto через requireNativeModule — так же в той статье ExpoSecureStore.ts доставал из реестра свой модуль. Как этот реестр устроен на телефоне, разберёт статья «Модуль — это запись в реестре» в главе 9. А рядом лежит ExpoCrypto.web.js — файл того же модуля для веба, который сборщик выбирает по суффиксу платформы. Веб-версия берёт тот самый globalThis.crypto, что сработал в песочнице:

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

Пакеты Expo в песочнице не собираются — expo-modules-core тянет TurboModuleRegistry, которого нет в react-native-web, — поэтому expo-crypto здесь только листинг, а примеры дальше обходятся приставками A и B. Можно было бы давать randomUUID() каждой строке и обойтись без счётчика вовсе — многие приложения так и делают: хранить счётчик не нужно, цена — 36 символов в каждом имени и в каждой ссылке. Устройство + счётчик короче и читается глазами — «A-3», — но обязывает хранить счётчик так же надёжно, как журнал: иначе после перезапуска счёт начнётся с единицы и имена повторятся (задача 1). В песочнице же годится только счётчик, потому что эталон вывода не должен плавать.

Соблазн — id, который сам сортируется по времени создания: UUID версии 7 и ULID начинаются с 48 бит миллисекунд по часам устройства, и лента сортируется по id без всякого поля даты. Два телефона записывают по подходу. Прогноз: чей id окажется первым?

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

Первым вышел B, хотя записал на две минуты позже: его часы отстают, а id несёт показания часов, а не время. Уникальности это не мешает, мешает только вере в порядок. Часы телефона человек переводит руками, и уходят они сами, поэтому порядок создания между устройствами такой id не гарантирует. Что делать с врущими часами, решает статья «Кто прав, если оба правы» в главе 7.

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

ПолеЧто этоКому понадобится
opIdимя самой операции: устройство и свой счётчик, «A:1»серверу — помнить по нему вердикт и на повтор отдавать тот же ответ; клиенту — узнать свою операцию, вернувшуюся с сервера (глава 7)
rowIdимя строки, которую операция меняет, «A-1»свёртке: по нему операции собираются в строки
fieldsтолько изменённые поля, а не строка целикомслиянию: правка веса с телефона и правка повторов с веба не затрут друг друга
atвремя по часам устройства: в примерах счётчик now() из подложки, на телефоне Date.now()правилу конфликтов, которое решает, чья правка поля новее, — и выясняет, что часы врут

Хранилище из прошлой статьи — в подложке, журнал пишется поверх него: операция ложится в журнал, а её результат — в хранилище, чтобы экраны, подписанные через useSyncExternalStore, узнали о записи как раньше. Имена строк выдаёт сам журнал. Сценарий: создать тренировку, записать в неё подход и поправить вес. Прогноз: какие строки журнала напечатаются и совпадёт ли свёртка журнала с тем, что лежит в хранилище?

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

Строк две, операций три: правка веса — отдельная операция с одним полем kg. Подход A-2 ссылается на тренировку A-1, созданную двумя строками выше, без единого ответа сервера. Строка — это reduce из части I по журналу: apply накладывает поля операции на то, что было, начиная с пустоты. Хранилище держит результат свёртки готовым, чтобы экран не пересчитывал журнал на каждой отрисовке, но правда — журнал. Строки из журнала собираются заново всегда, журнал из строк — никогда: по строке не видно, что вес правили. На этом стоит следующая статья: на диск ляжет журнал, а строки после перезапуска соберёт свёртка.

Неотправленные операции и есть исходящая очередь. Пока сервера нет, не отправлено всё, и outbox отдаёт журнал целиком — это будущая пачка, которую в главе 7 заберёт синхронизация. Из очереди будут уходить операции, на которые сервер вынес вердикт.

Это не оптимистичный ответ из статьи про useOptimistic части IV. Там экран показывал то, чего сервер ещё не подтвердил, и откатывал показанное при ошибке. Здесь запись на телефоне и есть результат: сеть пропала — откатывать нечего, операция просто ждёт в очереди. Что делать, если сервер операцию отвергнет, решает глава 7.

У записи теперь две половины: строка в хранилище и операция в журнале. Экран читает первую, синхронизация отправит вторую, и если между ними случится сбой, половины разойдутся. Очевидный порядок — сначала экран, пусть человек видит подход сразу, потом журнал. Сравним его с записью одним шагом. В песочнице процесс не уронить, поэтому сбой изображает рубильник из подложки: после crash.armed = true ближайший crash.here() бросает ошибку. Стоит он в одном месте — в сборке операции, где на телефоне выдаётся id и читаются часы. Прогноз для обоих порядков: сколько строк и сколько операций останется, если сбой придётся на второй подход?

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

Порядок «экран сразу» оставил две строки и одну операцию. Подход A-2 виден в ленте, и человек уверен, что он записан, — но в очереди его нет, и синхронизация о нём не узнает никогда: он живёт только в памяти этого телефона. Запись одним шагом не оставила от сбоя ничего: строка одна, операция одна. Неудачной записи нет ни на экране, ни в очереди, и человек, увидев ошибку, запишет подход заново. Разница только в порядке: всё, что может упасть, стоит до первой записи, а сами записи идут подряд, без единой строки между ними.

В памяти этого достаточно: между log.push и store.write падать нечему, а смерть процесса уносит обе половины разом. На диске — нет: две записи на диск — два отдельных действия, и систему, которая убивает процесс, порядок строк в коде не остановит. Там один шаг делается одним из двух способов. Первый — писать одну половину: на диск ложится только журнал, а строки собирает свёртка; это следующая статья. Второй — транзакция базы, которая при сбое откатывает обе половины: withTransactionAsync в expo-sqlite, последняя статья главы.

Журнал вместе с хранилищем переезжает в подложку следующей статьи: createJournal, apply и fold. Там процесс «убьют» по-настоящему — выбросят память, — и журнал придётся поднимать с диска.

  • Номер от сервера без сети не получить, а имя строке нужно в момент нажатия: подход ссылается на тренировку, созданную тут же. Временный id с подменой после ответа требует переписать каждую ссылку, вплоть до ключей строк списка.
  • Имя рождается на телефоне: устройство + счётчик. Простой счётчик на двух устройствах совпадает, и при слиянии одна строка затирает другую вместе со ссылками на неё.
  • Имя устройства — UUID версии 4, один раз при первом запуске. crypto.randomUUID() в песочнице работает, но компилятор набора rn прав: на телефоне глобального crypto нет, замена — randomUUID() из expo-crypto. Id с часами впереди порядка между устройствами не гарантирует.
  • Каждое изменение — операция { opId, rowId, fields, at }. Строка — свёртка журнала, неотправленные операции — исходящая очередь, будущая пачка синхронизации.
  • Строка и операция пишутся одним шагом: всё, что может упасть, — до первой записи. Иначе правка видна на экране, но не уедет никогда. На диске один шаг — это журнал со свёрткой или транзакция базы.

1/4Почему подход, записанный в зале без сети, не может получить номер от сервера так, как это делала заглушка «сервер главный»?

2/4Телефон и веб-версия нумеруют строки своим счётчиком с единицы. Что будет, когда копии сойдутся?

3/4Пример rn с crypto.randomUUID(): компилятор пишет «Не удается найти имя "crypto".», а вывод — «randomUUID: 36 символов». Что будет на телефоне с React Native 0.86?

4/4Экран записал подход в хранилище, а операция в журнал не легла: между ними случился сбой, который приложение пережило. Что станет с подходом?

Счёт после перезапуска

Здесь журнал держит строки сам, в Map, а при создании получает журнал, поднятый с диска, и собирает строки свёрткой — apply и fold лежат в подложке. Первый запуск создал тренировку и подход. После перезапуска новая тренировка получила имя, которое уже занято, и затёрла старую; повторилось и имя операции. Сделайте так, чтобы после перезапуска счёт продолжался с того места, где остановился.

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

Последний номер этого устройства можно найти в самом журнале — и для строк, и для операций:

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

Вывод: строки: A-1 ноги | A-2 подход | A-3 спина и opId: A:1 A:2 A:3. Имена других устройств счёт не трогает: у них другая приставка. Повтор opId опаснее, чем кажется: в главе 7 сервер помнит вердикт по имени операции, и новая операция со старым именем получит вердикт старой: её примут за повтор и не применят.

Восстанавливать счётчик из журнала можно, пока журнал не чистят. В главе 7 из журнала будут уходить принятые сервером операции, и тогда максимум по журналу может откатиться назад. Поэтому счётчик в итоге хранят отдельно — рядом с журналом и в том же шаге записи.

Черновик, который переписал журнал

Форма подхода после «Сохранить» не закрывается: человек меняет вес в том же черновике и сохраняет следующий подход. На экране веса верные, а в журнале — очереди на отправку — первый подход задним числом стал вторым. Почините журнал, не трогая форму.

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

Операция держала не поля, а ссылку на объект формы, и правка черновика меняла уже записанную операцию. Журнал должен хранить свою копию:

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

Вывод: экран: 80, 85 и журнал: 80, 85. Экран был прав с самого начала, потому что apply собирает новую строку разворотом полей — это та же поверхностная копия. Поля подхода — числа и строки, поэтому поверхностной копии хватает (статья «Копирование объектов» части I). Без копии беда не ограничилась бы очередью: после перезапуска строки соберёт свёртка журнала, и первый подход станет 85 кг и на экране.