Выпуск: сборка, магазин, первое обновление
Здесь будет встроен видео-разбор темы (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 наоборот — главная копия дневника лежит на телефоне («Главная копия — в телефоне»), и поднимает её лестница, которая умеет идти только вверх.
Посмотрим на это запуском. В подложке лежат две вещи: база телефона — строки и номер схемы, — и запуск бандла, доводящий отставшую базу до ступеньки этого выпуска:
Выпуск 1.1 разводит план и факт повторов: вместо одного reps в строке появляется repsDone. Миграция написана самым естественным образом — переложить значение и убрать старое поле. Обновление ушло по воздуху, а на следующий день его откатили: обычное дело, ради этого канал и переключают. Прогноз: что покажет экран после отката, что будет лежать в базе и какая у неё окажется схема?
Первая строка — то, чего и ждали. Вторая — цена: откатанный бандл читает reps, а его в строках больше нет, и дневник состоит из прочерков. Подходы при этом целы, они в базе, — но человек этого не знает: он видит пустой дневник и идёт писать в отзывах, что приложение потеряло тренировки. Третья строка объясняет, почему дальше будет только хуже: подход, записанный уже на откатанном бандле, лёг в reps, а прежние два живут в repsDone. Одна таблица, два поля, и сводить их потом придётся руками.
Схема осталась второй в обеих строках — схема базы: 2. Лестница знает, как подняться, и не знает, как спуститься: ступеньку вниз никто не пишет, потому что она обязана угадать, что делать с данными, появившимися наверху. Отсюда правило, которое стоит принять до первого выпуска: по воздуху едут только те миграции, которые переживут возврат на предыдущий бандл.
Откат делает не человек у пульта, а телефон — сам, молча и в любую минуту: канал переключили на прежнюю ветку, время публикации у неё старее, приложение запускается на том бандле, который ему велели. Поэтому «мы просто не будем откатываться» — это не план.
Лечится это не хитростью, а порядком: миграция разрезается надвое, и по воздуху едет только первая половина. Расширяющая ступенька добавляет: новое поле появляется рядом со старым, код выпуска пишет оба и читает любое. Сужающая убирает старое — и уезжает сборкой в магазин, когда откатываться уже некуда. Это то же правило expand/contract, которым выкатывают миграции серверной базы (в части VI ему отведена своя статья), но причина здесь другая: там откат делаете вы и по своему решению, здесь — телефон и без вас.
Та же правка, написанная расширяющей ступенькой. Обновление ушло, его откатили, на откатанном бандле записали ещё подход, потом обновление вернули, а сужение уехало следующим магазинным выпуском 1.2. Прогноз: что видит каждый бандл на своём шаге и какие поля останутся в строках в конце?
Откат больше ничего не ломает: 1.0 читает reps, и он на месте — расширяющая ступенька его не трогала, а запись из 1.1 кладёт оба поля. Подход, записанный на откатанном бандле, обновление читает через repsDone ?? reps: это вторая половина приёма — пока живы оба поля, читать надо оба.
Сужение в 1.2 — единственное место, где старое поле исчезает, и ступенька там не просто удаляет. Сперва она доливает repsDone тем строкам, у которых его нет, и только потом убирает reps. Иначе потеряются ровно те строки, которые записаны откатанным бандлом, — это первая задача статьи.
Ступенька 1.2 обязана уметь и это: человек ставит приложение из магазина раз в полгода, обновление по воздуху ему не досталось, и база у него всё ещё первой схемы. Прогноз: что он увидит после установки 1.2?
Работает по той же причине, по которой уцелел откат: ступенька доливает, а не предполагает. Настоящая лестница на телефоне идёт по ступенькам и помнит своё место в PRAGMA user_version, а гонит её onInit у SQLiteProvider («expo-sqlite: настоящая база в телефоне»); каждая ступенька обязана быть готовой к тому, что предыдущая до этого телефона не доехала.
В проекте это выглядит так — и главное в листинге не SQL, а комментарии: они объясняют, почему одна ступенька уезжает по воздуху, а вторая ждёт магазина.
Соблазн сузить сразу понятен: две миграции вместо одной, два выпуска вместо одного, и старое поле мозолит глаза месяцами. Цена расширения — лишняя колонка. Цена сужения по воздуху — дневники, которые человек считает потерянными.
Расширение спасает от отката. Остаётся вторая половина той же беды — телефоны, до которых обновление не доехало, и их всегда больше, чем кажется: кто-то не открывал приложение месяц, у кого-то выключено автообновление в магазине, а кому-то обновление не отдал сам сервер — замок честно ответил 204, потому что сборка старая («runtimeVersion: замок, который бережёт установленные приложения»). Все они продолжают записывать подходы и отправлять их — операциями старой формы.
Отвергать такие операции нельзя. По контракту из «Сервер — партнёр, а не хозяин: журнал, курсор, приём пачки» отвергнутую операцию телефон убирает из очереди и пересобирает строку без неё — честная запись человека пропадает. Отказ на всю пачку не лучше: очередь застревает навсегда, а застрявшая очередь в local-first — это не «данные не сохранились», а «дневник человека остался на одном телефоне». Поэтому операция несёт номер схемы, а поднимает её принимающая сторона — теми же ступеньками, что и телефон. Контракт остаётся прежним, sync.push, и в части VI лестница переезжает в настоящую процедуру вместе с ним.
Месяц после выпуска: одна операция приходит с обновлённого телефона, другая — с того, что остался в магазинной версии. Прогноз: что окажется в строках сервера и что из этого увидит лента в 1.1 — без лестницы и с ней?
Без лестницы сервер не соврал ни в одном поле: он сохранил то, что прислали. Соврала лента — она читает repsDone, а у приседа его нет, и подход выглядит пустым. Дальше хуже: правка того же подхода с обновлённого телефона добавит repsDone рядом, и в одной строке окажутся оба числа из разных эпох приложения, а слияние по полям будет честно сравнивать каждое само с собой («Кто прав, если оба правы»).
С лестницей старая операция становится новой до того, как коснётся строки, и разница между телефонами исчезает там, где ей и место, — на входе. Поднимает её принимающий, а не отправитель, и это не вопрос вкуса: бандл, выпущенный до 1.1, про новую схему не знает вовсе и узнать не может. Ступенька в листинге уже сужающая: пока на руках есть бандлы 1.0, сервер не убирает reps, а добавляет repsDone рядом, — когда убирать, решает «Кто выкатывается первым».
Тот же подъём нужен ещё в одном месте — при загрузке файла. Выгрузка из «Дневник принадлежит вам: выгрузка и загрузка обратно» кладёт рядом с операциями номер схемы: { app, schema, device, ops }. Годовалый архив — это та же пачка операций старой формы, только пришедшая не по сети, а из файла, и обращаться с ней надо так же. Это вторая задача статьи.
Номер схемы ставится на операцию в момент записи, а не в момент отправки: операция может пролежать в очереди неделю и уехать уже после обновления. Отметка «какой формы эти поля» принадлежит полям, а не пачке — ровно как отметка времени принадлежит операции, а не сеансу связи.
Осталось решить, в каком порядке всё это выпускается, и правило тут одно: первым выкатывается тот, кто принимает. Сервер учится понимать обе формы и уезжает в прод раньше, чем первый телефон получит обновление.
Что бывает иначе, видно запуском — и это единственный пример статьи, где работает собранная за главы 6 и 7 машинерия целиком: хранилище, журнал операций и сервер-партнёр — та самая миниатюра в этой же памяти, из описи подмен («Подход»: что в подложке было ненастоящим). Сервер выпущен прежним: его проверка требует от подхода exercise, weightKg и reps. Телефон получил обновление 1.1 и пишет repsDone. Прогноз: что ответит сервер, что станет с подходом на экране и что изменится, если тот же выпуск будет писать оба поля?
Сервер отверг операцию по имени поля, и клиент сделал ровно то, чему его учили: выбросил отвергнутую операцию и пересобрал строку («Цикл синхронизации: отправить, забрать, не задвоить»). Пересобирать было не из чего — это была первая операция строки, — и подход исчез с экрана. Вторая строка вывода тут страшнее первой: ждут отправки: 0. Очередь пуста, индикатор говорит «сохранено», ошибки человеку никто не показал; с точки зрения клиента выпуск прошёл штатно.
Второй телефон пишет оба поля — и тот же самый сервер принимает его без единого отказа. Это то же расширение, что и в базе, только на проводе: пока живы обе стороны, операция несёт и старое поле, и новое.
Отсюда весь порядок выпуска, и заодно ответ на вопрос, почему выпусков четыре, а не один. Первым — сервер, понимающий обе формы. Вторым — сборка в магазин: натив, расширяющая ступенька, запись обоих полей. Третьим — обновление по воздуху, которому теперь есть куда приехать. Четвёртым, много позже, — сужение, и не по календарю, а по данным: пока на сервер приходят операции старой формы, старая форма жива.
Отказ по операции — не поломка протокола, а его работа: сервер обязан отвергать мусор, иначе в дневник приедет что угодно. Беда в другом — отвергнутой оказалась честная запись человека, и виноват в этом не протокол, а порядок выпуска.
Теперь сами команды. Что печатает каждая из них, как устроены профили сборки, треки магазинов и каналы обновлений, разобрано в главах 10 и 11; здесь важен только порядок — тот, в котором их запускают для выпуска «Подхода».
Шаг 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 так, чтобы не потерялось ничего.
Строка «тяга» записана бандлом 1.0, и repsDone у неё нет: расширяющая ступенька к тому времени уже отработала и второй раз не пойдёт. Значит, сужение обязано сперва долить:
Вывод становится 1.2 видит: жим 5, тяга 8 | схема базы: 3.
Правило общее: ступенька не вправе предполагать, что предыдущая до этого телефона доехала. Её могли откатить, её могли пропустить (приложение поставили из магазина сразу с 1.2), а данные в базе всё равно чьи-то. Поэтому каждая ступенька сначала приводит строки к своей форме и только потом убирает лишнее.
Архив, снятый до смены схемы
Человек загружает в приложение свой файл годовой давности — тот самый, из статьи «Дневник принадлежит вам». Внутри честно записано schema: 1, а приложение давно живёт на второй схеме. Загрузка написана прямо: взять операции и отдать их журналу. Запустите, посмотрите на ленту и почините чтение файла.
Файл — это пачка операций старой формы, и обращаться с ней надо как серверу с операциями старого телефона: поднять лестницей на входе. Имя операции и её отметка времени при этом не меняются — на них держится узнавание повторной загрузки:
Лента становится жим 5, присед 3, а число загруженных операций не меняется: их по-прежнему две.
Соблазн, которого стоит избежать: «поднять» файл, переписав в операциях отметку времени на сегодняшнюю. Это уже разбиралось в статье про выгрузку — годовалый архив тогда объявит старые правки самыми свежими и побьёт ими всё, что сделано за год. Лестница меняет форму полей и не трогает ни opId, ни at.
Лестница, которая не дошла до верха
У сервера теперь две ступеньки: первая развела план и факт повторов, вторая переименовала поле веса. Пришли две операции из одних суток — с телефона в магазинной версии и с телефона, получившего 1.1. Запустите и почините подъём так, чтобы обе строки оказались в сегодняшней форме.
Номер последней схемы записан числом и отстал от лестницы на одну ступеньку. Считать его надо по самой лестнице:
Тогда печатается B:3 → {"exercise":"присед","repsDone":3,"weightKg":100} и C:2 → {"exercise":"тяга","repsDone":8,"weightKg":120}: обе операции доведены до третьей схемы, каждая — своим числом ступенек.
Ошибка эта живучая, потому что незаметная: сервер не падает, операции принимаются, а поле weight тихо приезжает в строки рядом с weightKg. Числом такие вещи не задают вовсе — их выводят из того же списка, по которому идёт подъём.