Выпуск: сборка, магазин, первое обновление

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

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

«Подход» дописан: он пишется в зале без сети, синхронизируется с партнёром, отдаёт дневник файлом и читается голосом. Осталось то, ради чего всё затевалось, — выпустить его. Сборка и магазины разобраны в главе 10, обновления по воздуху — в главе 11, и заново они здесь не объясняются: эта статья про порядок, в котором всё перечисленное делается с настоящим приложением, и про одну вещь, которой у приложения на запросах не бывает вовсе. Бандл возвращается к прежней версии одной командой. Схема данных на телефоне не возвращается ничем — потому что главная копия дневника лежит там же. Отсюда весь разговор: какие миграции можно выпускать по воздуху, что делать с телефонами, до которых обновление не доехало, кто выкатывается первым и на что смотреть в первые сутки. Сборка, отправка и выкат показаны командами: EAS и expo-updates в предпросмотре не выполняются вовсе. Запускаются здесь модели — лестница схемы, приёмная сервера и собранный за главы 6 и 7 дневник, — и на них видно ровно то, что ломается в настоящем выпуске.

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

Выпуск кажется одним событием — «выкатили версию», — а на самом деле их три, и едут они врозь. Нативная часть уходит в магазин: новые модули, иконка, номер версии; путь до человека — дни, а установленные приложения остаются прежними, пока он сам не обновится. JS-бандл уходит по воздуху: минуты, замок runtimeVersion и отпечаток набора нативных источников. Третья часть едет внутри бандла и обратно вместе с ним не возвращается — это схема данных на телефоне.

Что меняетсяЧем доставляетсяКак отменить
натив: модули, иконка, права, версияeas build и eas submit («EAS Build: сборка в облаке без Xcode», «Магазины: App Store, Google Play, RuStore и первая отправка»)только следующей сборкой; у кого не обновилось, остаётся прежняя
JS-бандл: экраны, логика, текстыeas update, если отпечаток не изменился («Что летит по воздуху, а что требует пересборки»)канал на прежнюю ветку или переиздание группы («EAS Update: каналы, ветки и вес обновления»)
схема данных на телефонеедет внутри бандла: лестница миграций поднимает базу до первого кадра («Диск под памятью: перезапуск и миграция схемы»)ничем: ступеньки «назад» в лестнице нет

В приложении на запросах третьей строки просто нет: схема живёт в базе на сервере, телефон держит копию, и откат бандла возвращает всё как было. В local-first наоборот — главная копия дневника лежит на телефоне («Главная копия — в телефоне»), и поднимает её лестница, которая умеет идти только вверх.

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

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

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

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

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

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

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

Лечится это не хитростью, а порядком: миграция разрезается надвое, и по воздуху едет только первая половина. Расширяющая ступенька добавляет: новое поле появляется рядом со старым, код выпуска пишет оба и читает любое. Сужающая убирает старое — и уезжает сборкой в магазин, когда откатываться уже некуда. Это то же правило expand/contract, которым выкатывают миграции серверной базы (в части VI ему отведена своя статья), но причина здесь другая: там откат делаете вы и по своему решению, здесь — телефон и без вас.

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

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

Откат больше ничего не ломает: 1.0 читает reps, и он на месте — расширяющая ступенька его не трогала, а запись из 1.1 кладёт оба поля. Подход, записанный на откатанном бандле, обновление читает через repsDone ?? reps: это вторая половина приёма — пока живы оба поля, читать надо оба.

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

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

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

Работает по той же причине, по которой уцелел откат: ступенька доливает, а не предполагает. Настоящая лестница на телефоне идёт по ступенькам и помнит своё место в PRAGMA user_version, а гонит её onInit у SQLiteProvider («expo-sqlite: настоящая база в телефоне»); каждая ступенька обязана быть готовой к тому, что предыдущая до этого телефона не доехала.

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

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

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

Расширение спасает от отката. Остаётся вторая половина той же беды — телефоны, до которых обновление не доехало, и их всегда больше, чем кажется: кто-то не открывал приложение месяц, у кого-то выключено автообновление в магазине, а кому-то обновление не отдал сам сервер — замок честно ответил 204, потому что сборка старая («runtimeVersion: замок, который бережёт установленные приложения»). Все они продолжают записывать подходы и отправлять их — операциями старой формы.

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

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

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

Без лестницы сервер не соврал ни в одном поле: он сохранил то, что прислали. Соврала лента — она читает repsDone, а у приседа его нет, и подход выглядит пустым. Дальше хуже: правка того же подхода с обновлённого телефона добавит repsDone рядом, и в одной строке окажутся оба числа из разных эпох приложения, а слияние по полям будет честно сравнивать каждое само с собой («Кто прав, если оба правы»).

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

Тот же подъём нужен ещё в одном месте — при загрузке файла. Выгрузка из «Дневник принадлежит вам: выгрузка и загрузка обратно» кладёт рядом с операциями номер схемы: { app, schema, device, ops }. Годовалый архив — это та же пачка операций старой формы, только пришедшая не по сети, а из файла, и обращаться с ней надо так же. Это вторая задача статьи.

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

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

Что бывает иначе, видно запуском — и это единственный пример статьи, где работает собранная за главы 6 и 7 машинерия целиком: хранилище, журнал операций и сервер-партнёр — та самая миниатюра в этой же памяти, из описи подмен («Подход»: что в подложке было ненастоящим). Сервер выпущен прежним: его проверка требует от подхода exercise, weightKg и reps. Телефон получил обновление 1.1 и пишет repsDone. Прогноз: что ответит сервер, что станет с подходом на экране и что изменится, если тот же выпуск будет писать оба поля?

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

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

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

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

Отказ по операции — не поломка протокола, а его работа: сервер обязан отвергать мусор, иначе в дневник приедет что угодно. Беда в другом — отвергнутой оказалась честная запись человека, и виноват в этом не протокол, а порядок выпуска.

Теперь сами команды. Что печатает каждая из них, как устроены профили сборки, треки магазинов и каналы обновлений, разобрано в главах 10 и 11; здесь важен только порядок — тот, в котором их запускают для выпуска «Подхода».

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

Шаг 1 стоит проговорить отдельно. Если отпечаток изменился, обновления по воздуху не будет вовсе: телефон получит 204 и останется на вшитом бандле. Это правильно — правка, которой нужен новый натив, не должна доезжать до старой сборки, — и плохо здесь только одно: узнать об этом после публикации, когда людям уже сказано, что починили.

А перед всем этим идёт проверка, и она дешевле любого отката. Собрана она из того, что уже написано в этой главе и в главах 10–11, — пройти её можно за один вечер.

Что проверитьЧемГде разобрано
дневник сходится на двух устройствахпрогон приёмки: зал без сети, правка на вебе, удаление, сверка с эталоном«Подход»: что в подложке было ненастоящим
запись переживает смерть процесса и авиарежимadb shell am kill, xcrun simctl terminate, авиарежим рукамитам же, вторая полка подмен
экран читается голосом, мишени не мельче 44 точекTalkBack и VoiceOver свайпами, крупный системный шрифт одной командой«Доступность и мелочи, из-за которых приложение кажется чужим»
вчерашний архив загружается в сегодняшнее приложениевыгрузка в файл и загрузка обратно, с подъёмом операций по номеру схемы«Дневник принадлежит вам» и задача 2 этой статьи
правка долетит обновлением, а не потребует сборкиfingerprint:generate до и после правки«Что летит по воздуху, а что требует пересборки»
очередь пуста, а предупреждения называют число«Выйти» и «Стереть копию» при непустой исходящей очереди«Экран настроек: тема, единицы, сброс»

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

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

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

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

Симптом в первые суткиЧем лечитсяЗа сколько
падает на старте у тех, кто получил обновлениеканал на прежнюю ветку, при нужде переиздание группыминуты
растут отказы операций с одной и той же причинойсервер: приём обеих форм и лестница по номеру схемы. Откатом бандла это не лечится — база на телефонах уже поднятачасы
очереди не пустеют, а отказов нетсмотреть 401 и сеть: синхронизация на паузе — не ошибка, а паузачасы
падает у всех, включая тех, кто без обновленияновая сборка и отправка в магазины; по воздуху натив не летитдни

На этом «Подход» закончен. За двенадцать глав он из первого экрана на View и Text стал приложением, которое пишется в зале без сети, держит главную копию на телефоне, синхронизируется журналом операций, переживает откат обновления и выкладывается в три магазина. Пятнадцать миниатюр, собранных по дороге — от своего слияния стилей до worklet в настоящем втором потоке, — так и лежат в подложке и работают до последней строки этой статьи.

Чего у «Подхода» нет — второй половины. Миниатюра из главы 7 честно отвечает на sync.push и sync.pull, но живёт в той же памяти, что и экраны, а горизонт курсора, срок надгробий и снимок для нового телефона ей не по силам. Этим занимается часть VI: там будет настоящий сервер на Node, и контракты у него будут те же самые — те же имена, тот же смысл, та же пачка. Экраны «Подхода» при этом не изменятся ни строкой. А пока в части V остаётся последняя глава — уже не про «Подход», а про чужие пакеты на телефоне: полка React Native и Expo.

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

  • Выпуск — это три выката с разной скоростью и разной отменой: натив уходит в магазин, JS-бандл летит по воздуху, а схема данных на телефоне едет внутри бандла и обратно вместе с ним не возвращается.
  • Поэтому по воздуху отправляют только расширяющие миграции: новое поле рядом со старым, запись обоих, чтение любого. Сужение — отдельным магазинным выпуском, и его ступенька сперва доливает, потом убирает: иначе потеряет строки, записанные на откатанном бандле.
  • Телефоны без обновления шлют операции старой формы ещё месяцами, и отвергать их нельзя — отвергнутая операция уходит из очереди, и запись человека пропадает. Каждая операция несёт номер схемы, а поднимает её принимающая сторона. Тот же подъём нужен при загрузке годовалого архива из файла.
  • Порядок выпуска: первым — тот, кто принимает. Клиент, уехавший раньше сервера, получил «нет полей: reps», выбросил операцию и убрал подход с экрана — молча, при пустой очереди и спокойном индикаторе «сохранено».
  • Команды идут по порядку: отпечаток и runtimeVersion — сборка — отправка в магазины — обновление на десятую часть с раскаткой. Если отпечаток изменился, обновления не будет вовсе: телефон получит 204 и останется на вшитом бандле.
  • В первые сутки смотрят не только на долю сессий без падений: свой светофор local-first — отвергнутые операции и очереди, которые не пустеют. Откатом бандла это не чинится, базы на телефонах уже подняты; чинит принимающая сторона.

1/4Обновление по воздуху несло миграцию схемы, и его откатили на прежнюю ветку. Что вернулось на телефон?

2/4Почему сужающую половину миграции — ту, что убирает старое поле, — выпускают сборкой в магазин, а не по воздуху?

3/4Телефон, не получивший обновление, через месяц отправляет операцию старой формы. Кто приводит её к новой схеме?

4/4Выпуск с новым полем уехал на телефоны раньше, чем обновился сервер. Что увидит человек?

Ступенька, которая убрала слишком рано

Выпуск 1.1 сделан правильно — расширяющей ступенькой, — а сужение в 1.2 написано в лоб: убрать reps, раз вместо него есть repsDone. Между этими двумя выпусками случился обычный день: обновление откатили, и человек записал подход на прежнем бандле. Запустите, посмотрите на вторую строку и почините ступеньку 1.2 так, чтобы не потерялось ничего.

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

Строка «тяга» записана бандлом 1.0, и repsDone у неё нет: расширяющая ступенька к тому времени уже отработала и второй раз не пойдёт. Значит, сужение обязано сперва долить:

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

Вывод становится 1.2 видит: жим 5, тяга 8 | схема базы: 3.

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

Архив, снятый до смены схемы

Человек загружает в приложение свой файл годовой давности — тот самый, из статьи «Дневник принадлежит вам». Внутри честно записано schema: 1, а приложение давно живёт на второй схеме. Загрузка написана прямо: взять операции и отдать их журналу. Запустите, посмотрите на ленту и почините чтение файла.

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

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

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

Лента становится жим 5, присед 3, а число загруженных операций не меняется: их по-прежнему две.

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

Лестница, которая не дошла до верха

У сервера теперь две ступеньки: первая развела план и факт повторов, вторая переименовала поле веса. Пришли две операции из одних суток — с телефона в магазинной версии и с телефона, получившего 1.1. Запустите и почините подъём так, чтобы обе строки оказались в сегодняшней форме.

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

Номер последней схемы записан числом и отстал от лестницы на одну ступеньку. Считать его надо по самой лестнице:

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

Тогда печатается B:3 → {"exercise":"присед","repsDone":3,"weightKg":100} и C:2 → {"exercise":"тяга","repsDone":8,"weightKg":120}: обе операции доведены до третьей схемы, каждая — своим числом ступенек.

Ошибка эта живучая, потому что незаметная: сервер не падает, операции принимаются, а поле weight тихо приезжает в строки рядом с weightKg. Числом такие вещи не задают вовсе — их выводят из того же списка, по которому идёт подъём.