Дневник принадлежит вам: выгрузка и загрузка обратно
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Настройки собраны, опись подмен составлена — и у local-first остаётся ровно одно невыполненное обещание. В статье «Главная копия — в телефоне» семь идеалов Ink & Switch были сверены честно, и напротив «долгого сейчас» стояло «частично: журнал выгружается в JSON и CSV и загружается обратно — глава 12». Эта статья и есть та глава 12. «Долгое сейчас» значит, что дневник обязан пережить и сервис, и само приложение: уехать в файл, который открывается без «Подхода», и вернуться обратно, когда человек этого захочет. Выгрузка выглядит работой на полчаса — сериализовали да отдали, — но решений в ней три, и каждое ошибается по-своему: что выгружать (снимок ленты или журнал), как загрузить обратно и не задвоить и в каком виде отдать файл человеку, а не программе. Все три проверяются запуском. А настоящий файл на диске и меню «Поделиться» придут листингом: expo-file-system и expo-sharing — пакеты Expo, и в предпросмотре они не собираются вовсе.
Повод для выгрузки бывает двух видов, и оба не гипотетические. Первый: сервис закрылся — с приложениями для тренировок это случалось не раз, и дневник за три года уезжает вместе с ним. Второй мягче: человек просто уходит в другое приложение и хочет забрать своё. В обоих случаях вопрос один: что считать «своим дневником» — то, что видно на экране, или то, из чего экран собран?
Ответов, как всегда, два, и оба звучат разумно. Первый: выгрузить ленту — список подходов, ровно как его показывает экран. Второй: выгрузить журнал операций, тот самый из «Журнала: имена и номера рождаются на телефоне». Проверим оба сразу: неделя дневника уезжает в файл, а потом поднимается на новом телефоне, у которого нет ни своего журнала, ни знакомого сервера. Прогноз: сколько подходов увидит новый телефон в каждом случае, сколько операций окажется у него в очереди на отправку и совпадёт ли вес жима?
Подходов поровну, и вес жима одинаковый: снимок не соврал ни в одном значении — он ведь собран той же свёрткой, что и лента. Разница в третьем числе. У телефона, поднятого из снимка, в очереди ноль операций: строки на экране есть, а рассказать о них новому серверу нечем — операций, из которых они собраны, в файле не было. Такой дневник читается, но не продолжает жить: он не уедет на второе устройство и не попадёт в резервную копию, пока человек не тронет каждую строку руками.
У телефона, поднятого из журнала, в очереди три операции — ровно те, что были написаны за неделю. Это не «лишние данные»: это и есть дневник в той форме, в какой его понимают все части «Подхода». Отсюда правило выгрузки: файл — это журнал, а снимок ленты рядом с ним годится ровно на то, на что годится распечатка.
| Что в файле | Что даёт | Чего не может |
|---|---|---|
| снимок ленты: строки как на экране | прочитать дневник любым инструментом, показать тренеру, построить график | продолжить синхронизацию: операций нет, отправлять нечего |
| журнал операций | поднять дневник целиком на новом устройстве и уехать дальше на сервер | быть прочитанным человеком без «Подхода»: это история правок, а не таблица |
Проверять выгрузку надо не глазами по файлу, а загрузкой на пустое устройство. Файл, который «выглядит правильно», и файл, из которого поднимается живой дневник, — разные файлы, и отличаются они как раз тем, чего в первом не видно.
Голый массив операций в файл не кладут. К нему добавляются две вещи, и обе не про красоту. Номер схемы — потому что файл переживёт несколько версий приложения, а поля со временем меняются: лестница миграций из «Диска под памятью: перезапуск и миграция схемы» должна знать, с какой ступеньки поднимать этот файл. Имя устройства — потому что по нему видно, чьи это операции, и оно же объясняет их имена вида A:2.
Загрузка устроена проще, чем кажется, и именно этим ценна: операция кладётся в журнал как есть, со своим opId и своей отметкой времени. Нового имени ей не дают — на этом и держится вся статья. Прогноз: человек выгрузил дневник и тут же загрузил файл обратно, а потом, не найдя видимого результата, загрузил его ещё раз. Что напечатают обе загрузки и сколько подходов останется на экране?
Метод, которым загрузка кладёт операцию, в подложке называется adopt, и вот он целиком — три решения в шести строках:
Соблазн понятный: операция приехала сегодня — пусть и время у неё будет сегодняшнее. Тогда загрузка годовалого архива объявит все старые правки самыми свежими, и слияние по полям честно отдаст им победу над всем, что было сделано за год («Кто прав, если оба правы: последняя запись, слияние по полям и часы»). Файл перестаёт быть архивом и становится машиной времени наоборот: чем древнее правка, тем она сильнее. Ровно это и разбирается в первой задаче статьи.
«Уже было: 3» стоит показать человеку словами, а не прятать. Человек, загрузивший файл и не увидевший ничего, решит, что загрузка не сработала, и пойдёт искать другой способ — обычно худший, вроде «удалить всё и загрузить заново».
Файл живёт своей жизнью: его отправляют себе в мессенджер, кладут в облако, открывают на новом телефоне, а потом ещё раз — на планшете. Каждое из этих устройств загрузит одни и те же операции и отправит их серверу как неотправленные: для них они новые. Вот здесь и выясняется, была ли выгрузка сделана правильно.
Прогноз: телефон записал два подхода с правкой и синхронизировался, после чего его файл открыли на двух новых устройствах, и оба синхронизировались тоже. Что ответит сервер на пачку второго устройства и сколько записей окажется в его журнале в конце?
Шесть операций ушли на сервер — по три с каждого устройства, — и ни одна не стала новой записью: в журнале сервера как было три, так и осталось. Отвечает за это память вердиктов из «Ответа потерялся»: сервер помнит, что A:1 он уже применял, и на повтор отдаёт duplicate, не трогая ни строки, ни номера. А возможна эта память потому, что имя операции родилось на телефоне («Журнал: имена и номера рождаются на телефоне»): будь номера серверные, те же операции приехали бы безымянными и получили бы по новому номеру — то есть по новой строке на каждое устройство.
Отсюда важное следствие для выгрузки: файл дневника можно раздавать свободно. Он не «применяется» — он предлагается, а что из него ново, решают имена операций, и решают одинаково на телефоне и на сервере.
Честная оговорка та же, что в «Что в подложке было ненастоящим»: сервер здесь миниатюра в этой же памяти, журнал — массив в памяти кадра, имя устройства — буква со счётчиком вместо randomUUID() из expo-crypto, а отметка времени — счётчик тактов вместо часов; duplicate такой сервер отвечает мгновенно. Но проверяется этим прогоном не скорость, а свойство протокола, и от настоящего сервера части VI требуется ровно оно — тот же вердикт по тому же opId сколько угодно раз.
Хороший способ проверить чужой формат выгрузки перед тем, как ему довериться: загрузить один и тот же файл дважды. Если записей стало вдвое больше, автор формата не считал загрузку обратно частью задачи — и данные в нём принадлежат вам только на словах.
JSON журнала человек читать не станет: это история правок, а не тренировки. Поэтому рядом с ним живёт вторая выгрузка — CSV, который открывается таблицей: отправить тренеру, построить свой график, посчитать объём за месяц. Вес в нём всегда в килограммах, как и в журнале: единицы — это вид, а не данные («Экран настроек: тема, единицы, сброс»), и файл, в котором половина строк в фунтах, не сложить ни в какую сумму.
У CSV есть ровно одно место, где ошибаются все, и название упражнения находит его само: «жим, узкий хват». Прогноз: как такая ячейка выглядит в файле, что из неё прочитает разбор через split(",") и сколько подходов окажется в дневнике, если этот же файл загрузить обратно?
Выгрузка сработала правильно: ячейка с запятой взята в кавычки — так CSV описан в RFC 4180, и так его ждут и Excel, и Numbers, и Google Sheets. А вот разбор через split(",") развалил строку: в упражнении осталось "жим с кавычкой, весом стало NaN, а повторами — 82.5, то есть вес, съехавший на столбец влево. Числа при этом не выглядят битыми, и такой импорт спокойно доезжает до дневника.
Но главное в последней строке: подходов стало четыре вместо двух. В CSV нет ни id строки, ни opId операции — их некуда положить в таблице, которую человек собирается читать глазами, — поэтому загрузка обратно не может узнать ни одной записи и заводит новые. Тот же файл, загруженный трижды, даст восемь подходов. Это не поправимо аккуратностью: без имён узнавать нечем.
| JSON журнала | CSV ленты | |
|---|---|---|
| для кого | для программы: это дневник в его настоящей форме | для человека и таблиц |
| что внутри | операции целиком: opId, rowId, поля, отметка | строки как на экране: упражнение, вес, повторы |
| загрузка обратно | «добавлено: 0, уже было: 3» — сколько угодно раз | каждая загрузка заводит новые строки: 2 стало 4 |
| надгробия | едут: удаление — такая же операция | не едут: удалённого в ленте нет |
Отсюда правило, которое стоит записать в самом приложении рядом с кнопками: CSV — вывод, JSON — выгрузка. Кнопка «Поделиться таблицей» и кнопка «Сохранить копию дневника» делают разное, и подписывать их одинаково нельзя.
Всё, что выше, — это строки в памяти. Файла у них нет: чтобы он появился, нужны два пакета Expo, и оба упираются в стену, названную в «expo-sqlite: настоящая база в телефоне». Модуль Expo зовёт requireNativeModule, тот импортирует TurboModuleRegistry, которого у react-native-web нет вовсе, — падение случится на импорте, до первой строки примера. Поэтому дальше листинги, а версии проверены в терминале:
Выгрузка — это три шага: собрать текст, положить его в файл, отдать файл системе. Папок для файла две, и выбор между ними не про вкус. Paths.cache — место, которое система вправе очистить, когда кончается место на диске; Paths.document — место, которое она не тронет. Файлу, который живёт ровно до конца меню «Поделиться», нужен кеш.
file.write() здесь синхронный и без await — так он и объявлен в 57.0.7: write(content: string | Uint8Array, options?: FileWriteOptions): void. Асинхронного в этой паре только isAvailableAsync и shareAsync, и второй ждёт не записи, а человека: промис исполнится, когда меню закроется. Чего shareAsync не сообщает — выбрал человек хоть что-нибудь или просто смахнул меню; в типах у него Promise<void>, и придумывать себе «пользователь поделился» здесь не из чего.
Загрузка обратно — системный выбор файла. В 57.0.7 он живёт прямо на File и отвечает размеченным объединением: успех — { result: File, canceled: false }, отказ — { result: null, canceled: true }. Проверка canceled здесь не вежливость, а то, без чего компилятор не отдаст файл («Размеченные объединения» части III):
В браузере у обоих пакетов есть веб-сборки, и они разные по честности. У expo-sharing она настоящая: SharingNativeModule.web.ts зовёт системное меню браузера, только опции у него свои — { title, text, url }, никаких UTI и mimeType. А у expo-file-system веб-сборка — заглушка: FileSystemFile с пустым конструктором, а pickFileAsync объявлен как () => Promise<void>. Поэтому в веб-версии «Подхода» файл собирается и отдаётся средствами браузера, а не этим пакетом, — тот самый случай, когда пара файлов по расширению из «Одного приложения, трёх платформ» не украшение, а единственный способ.
Кнопку выгрузки стоит поставить в настройки рядом с «Удалить дневник везде» — и не потому, что они похожи, а потому, что человек, нажимающий вторую, обязан увидеть первую. Это дешёвая вежливость, которая однажды спасёт чей-то год тренировок.
На этом «Подход» становится приложением, которое не держит человека: дневник пишется без сети, синхронизируется между устройствами и уезжает в файл, из которого поднимается обратно. Осталось то, что не видно ни в одном прогоне, — экран, который читают голосом и нажимают пальцем в перчатке. Об этом следующая статья, «Доступность и мелочи, из-за которых приложение кажется чужим».
- «Долгое сейчас» — тот идеал Ink & Switch, который в главе 6 был помечен «частично»: дневник обязан пережить и сервис, и приложение. Выгрузка — это не сериализация экрана, а выдача журнала.
- Снимок ленты и журнал дают на новом устройстве одинаковые строки и разное будущее: у поднятого из снимка в очереди ноль операций, и дневник дальше никуда не уедет; у поднятого из журнала — три, и он продолжает обычную жизнь.
- В файле лежат операции целиком плюс номер схемы и имя устройства, а загрузка кладёт операцию как есть — со своим
opIdи своей старой отметкой: повторная загрузка печатает «добавлено: 0, уже было: 3» и ничего не портит. Проставить отметку заново нельзя: тогда годовалый архив объявит старые правки самыми свежими и побьёт ими всё, что сделано за год. - Один файл, открытый на двух новых устройствах, не добавил серверу ни одной записи: на повтор он отдаёт тот же вердикт по тому же
opId. Это работает только потому, что имена операций рождаются на телефоне. - CSV — вывод для человека: ячейка с запятой берётся в кавычки по RFC 4180, а
split(",")её разваливает. Загружать CSV обратно как дневник нельзя: безidиopIdкаждая загрузка заводит новые строки — два подхода стали четырьмя. - На телефоне файл пишет expo-file-system 57.0.7 (
new File(Paths.cache, …),create,write,File.pickFileAsync), а меню «Поделиться» показывает expo-sharing 57.0.22 (isAvailableAsync,shareAsync). В предпросмотре не собирается ни один из них.
1/4Дневник выгрузили снимком ленты — строками, как на экране, — и подняли на новом телефоне. Чего он не сможет?
2/4Человек загрузил свой же файл обратно, и приложение напечатало «добавлено: 0, уже было: 3». Что это значит?
3/4Один и тот же файл открыли на двух новых устройствах, и оба синхронизировались. Что стало с журналом сервера?
4/4Почему выгрузку в CSV нельзя загружать обратно как дневник?
Архив, который вернул старый вес
Загрузка написана в лоб: взять из файла поля каждой операции и записать их журналом — тем же способом, каким пишет экран. В понедельник сняли архив, в среду на веб-версии подняли вес жима до 85, а в пятницу человек загрузил понедельничный файл. Запустите, посмотрите на третью и четвёртую строки и почините загрузку.
Операция должна лечь в журнал как есть — со своим именем и своей отметкой времени. Ровно это делает adopt:
Вывод становится честным: в пятницу загрузили архив: операций 0, вес на обоих устройствах остаётся 85, в журнале телефона одна операция, а у сервера две записи вместо трёх.
Что ломалось до починки: journal.change заводит операции новое имя и сегодняшнюю отметку. Понедельничная запись «жим, 80 кг» приехала в пятницу как самая свежая правка и по правилу слияния полей честно победила среду («Кто прав, если оба правы»). Через sync() она уехала и на веб-версию: вес откатился на обоих устройствах сразу, и человек не узнает об этом никогда — в дневнике просто стоит не тот вес.
Обратите внимание и на последнюю строку: у сервера появилась третья запись. Загрузка архива, написанная так, не только портит значения, но и раздувает журнал на каждое открытие файла.
Выгрузили только то, что видно
Тут выгрузка бережёт человека от лишнего: зачем везти в архив удалённое? Один подход записали по ошибке и удалили ещё на телефоне, и в файл он не попадает. Запустите, посмотрите на последние две строки и объясните, почему их две, а не одна.
Фильтр надо убрать: в архив едет журнал целиком, вместе с надгробиями.
Тогда в файле четыре операции вместо трёх, а на новом телефоне — два подхода и список жим, присед: удалённая тяга остаётся удалённой.
Почему это важнее, чем кажется: удаление в «Подходе» — не пропажа строки, а запись («Удаление — это запись: надгробие»). Выбросив из файла операцию { deleted: true }, выгрузка выбросила единственное свидетельство того, что подход удалён, — и оставила в нём три обычные операции, из которых дневник собирается заново. Удалённый подход воскрес на новом устройстве, а оттуда уехал бы и на сервер.
Ловушка общая для всей части: «показать только видимое» — правило экрана, а не хранилища. У ленты для этого есть visible и sets, а у выгрузки, у синхронизации и у диска работа другая — везти всё, включая то, что человек видеть не должен.