expo-sqlite: настоящая база в телефоне
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Глава собрала дневник целиком: хранилище в памяти, журнал операций, диск под памятью, лестница миграций и честный первый кадр. Диском был асинхронный ящик, и цена его названа в статье «Диск под памятью»: каждое нажатие переписывает весь журнал. Настоящая база пишет одну строку. В этой статье на место ящика встаёт expo-sqlite — и почти ничего из собранного не выбрасывается, а находит себе имя: транзакция вместо «одним шагом», PRAGMA user_version вместо номера схемы, событие базы вместо своей подписки. Запустить её здесь не выйдет, и у этого две причины, обе названные поимённо и обе проверенные. Поэтому настоящая база идёт листингом, а выполняется веб-файл того же интерфейса — приём из статьи «Одно приложение, три платформы».
Сначала честно о том, чего в песочнице не случится. Стена первая: expo-sqlite — модуль Expo, а его точка входа на телефоне состоит из двух строк, и вторая из них зовёт реестр нативных модулей (статья «Модуль — это запись в реестре» главы 9). Реестр, в свою очередь, первой же строкой импортирует TurboModuleRegistry из react-native:
Во входном файле react-native-web этого имени среди экспортов нет: вендорная копия TurboModuleRegistry.js в сборке лежит, но наружу пакет её не отдаёт. Значит, пакет не соберётся, даже если положить его рядом: падение случится на импорте, до первой вашей строки. Так же устроены expo-crypto из статьи про журнал и expo-secure-store из последнего раздела, и так же — любой другой модуль Expo в этой части.
Стена вторая интереснее, потому что веб-сборка у expo-sqlite есть. Рядом с ExpoSQLite.ts лежит ExpoSQLite.web.ts — тот самый выбор файла по расширению, — и в браузере он берёт сборку wa-sqlite, которая держит базу в отдельном потоке, в Worker. Асинхронные вызовы ходят туда обычными сообщениями. А синхронные — openDatabaseSync, execSync, getAllSync — ждать сообщения не могут: они обязаны вернуть значение сразу. Поэтому ответ приезжает через разделяемую память:
SharedArrayBuffer — часть языка, не браузерное имя, и в типах он есть. Но браузеры выдают его только страницам с кросс-источниковой изоляцией: ею страница обещает, что к ней не примешано чужое содержимое. Включается она заголовками COOP и COEP, а вложенному кадру — ещё и разрешением от родителя. Учебник ни того, ни другого не делает. Прогноз: что напечатает пример и что скажет компилятор?
Имени SharedArrayBuffer в кадре нет, и обращение к нему — не пустое значение, а ReferenceError: браузер не объявляет его вовсе. Atomics при этом на месте — они работают и с обычными буферами, — но ждать на них нечего. crossOriginIsolated равен false, и это и есть причина: не платформа, не React Native, а страница, на которой живёт кадр. Компилятор про SharedArrayBuffer промолчал — он часть ES2017 и в наборе типов есть, — зато нашёл crossOriginIsolated и location: «Не удается найти имя». Оба имени браузерные, а библиотеки DOM у набора rn нет. Опять тот случай из статьи «Что в этой песочнице настоящее», когда типы и рантайм расходятся, только теперь в обе стороны сразу.
Последние две строки — про вторую половину той же стены. Даже асинхронный путь начинается с создания Worker, а адрес ему считают от адреса страницы. У документа песочницы адрес about:srcdoc, относительный путь от него не считается ни во что — URL бросает TypeError. Тот же самый факт, из-за которого песочница учебника подставляет компилятору и React абсолютные адреса.
Стена бывает двух видов: «нельзя собрать» и «нельзя выполнить». У expo-sqlite здесь обе, и ни одна из них не про телефон.
На телефоне всего этого нет. Там нет ни Worker, ни разделяемой памяти в этой роли: база — нативная библиотека, и запрос к ней идёт через реестр модулей прямым вызовом. Поэтому дальше настоящая база — листинг, а выполняется веб-файл того же интерфейса.
Собранное за главу перечисляется коротко, потому что у каждой части теперь есть имя в expo-sqlite 57:
| Ваша миниатюра | В expo-sqlite 57 | Что меняется |
|---|---|---|
disk.setItem с журналом целиком | runAsync("INSERT …") | пишется одна строка, а не весь дневник |
fold на старте | getAllAsync("SELECT …") | строки уже лежат таблицей, свёртка остаётся неотправленному |
| строка и операция одним шагом | withTransactionAsync(task) | сбой между половинами откатывает обе |
| номер схемы в значении на диске | PRAGMA user_version | номер лежит в самом файле базы |
store.subscribe | enableChangeListener плюс addDatabaseChangeListener | о записи рассказывает база, а не ваш код |
| снимок по номеру версии | useLiveQuery из drizzle-orm | первый кадр приходит пустым |
Таблиц в базе две, и это первая настоящая перемена. sets — строки, теми же полями, что показывает лента; ops — журнал, и fields в нём лежит строкой JSON, потому что у правки поля любые. Строки больше не собираются свёрткой при каждом запуске: они уже на диске, и их можно спрашивать запросом — по тренировке, по упражнению, с сортировкой и пределом. Журнал остаётся ради неотправленного: из строк очередь не восстановить, об этом была вся прошлая статья.
withTransactionAsync — это и есть «одним шагом» из статьи про журнал. Две вставки внутри неё либо применяются обе, либо ни одна, и та беда, когда подход виден в ленте, но в очередь не попал, становится невозможной на диске, а не только в памяти. Одна оговорка честности: в документации этого метода прямо сказано, что транзакция не исключительная и другие асинхронные запросы могут вклиниться внутрь неё; когда порядок важен, есть withExclusiveTransactionAsync, но он помечен как недоступный в вебе.
Лестница миграций переезжает в onInit провайдера. В типах SQLiteProvider этот обработчик подписан словами «run database migrations or other setup tasks» — то есть авторы имели в виду ровно то, что вы написали руками в прошлой статье. Номер схемы теперь не ваше поле в значении, а PRAGMA user_version внутри файла базы:
Шаг таблицы стал ALTER TABLE, а шаг журнала — нет и не может: поля правки лежат в ops.fields строкой JSON, и ALTER TABLE до них не дотянется. Это ровно то, чем кончилась прошлая статья: у журнала своя лестница, и на настоящей базе она остаётся обычным циклом по строкам ops. Внутри транзакции идут и шаг, и новый номер — разойтись им негде.
А три состояния открытия провайдер уже умеет, и написаны они в нём теми же словами. С useSuspense промис открытия отдаётся use() — только не тому, что из React, а его собственной копии, объявленной тут же в файле: она так же бросает промис, и Suspense ловит его так же. Механика из части IV, заново не разбираем, а отказ уходит в границу ошибок. Без него провайдер, пока база открывается, возвращает null:
То есть «не открылась» у провайдера — это исключение, которое летит в границу ошибок, и никакого сброса базы в нём нет. Правило прошлой статьи — «не открылась» отдельный экран, а не пустой дневник — с настоящей базой не меняется, меняется только то, кто бросает.
Раз настоящая база в песочнице не запустится, а рассказывать про неё надо на запуске, берём приём из статьи «Одно приложение, три платформы»: journal-db.ts для телефона рядом с journal-db.web.ts для браузера, общий тип — в journal-db.types.ts, и сборщик выбирает файл сам. Экраны об этом не знают: они видят один JournalDb. Тем же приёмом у автора сделано хранилище сессии, и тем же — ExpoSQLite.web.ts внутри самого expo-sqlite.
Веб-файл кладёт обе «таблицы» в один ящик из прошлой статьи. Сценарий тот же, что там: записать подход, поправить вес, убить процесс и открыть базу заново. Прогноз: сколько операций поднимется с диска и что покажет лента?
Те же две строки, что печатал ящик в статье «Диск под памятью», — и это главное, что нужно от подмены по расширению: экран и сценарий не заметили, что под ними другой файл. Разница внутри. Строки пришли из sets, а не из свёртки журнала, а change написана так, чтобы обе таблицы уехали на диск одним setItem: одно значение — одна запись. На телефоне ту же неделимость даёт транзакция, и что бывает без неё — задача 1.
Ещё одно отличие видно в типе: sets() и outbox() возвращают промис, хотя данные лежат рядом, в памяти кадра. Так сделано нарочно — у настоящей базы getAllAsync асинхронный, и экран, написанный на синхронное чтение, на телефоне пришлось бы переписывать. Из-за этого же промиса первый кадр экрана снова окажется пустым — следующий раздел.
Затем же, зачем и раньше. По строке не видно, что вес правили и что правка ещё не уехала, — а синхронизация отправляет именно операции. В главе 7 из ops будут уходить те, на которые сервер вынес вердикт, а чужие изменения будут ложиться в sets мимо журнала. Тогда и станет видно, зачем таблицы две: sets — то, что показывает экран, ops — то, что телефон должен миру.
У хранилища из первой статьи главы был subscribe: запись будила подписчиков, потому что запись шла через него же. У базы источник событий свой — addDatabaseChangeListener, и он рассказывает о любой записи в таблицу, кем бы она ни была сделана. Включается он не сам: enableChangeListener в опциях открытия, и в типах об этом сказано отдельной строкой. В приложении автора это записано в комментарии прямо у вызова:
Поверх этого события построен useLiveQuery из drizzle-orm/expo-sqlite — тот самый живой снимок. Сигнатура у него (query, deps?), а возвращает он { data, error, updatedAt }. Внутри — обычный React: состояние, эффект, подписка на событие базы и перезапрос по нему.
Два места стоит заметить сразу. Первое: начальное data — пустой массив, а сам запрос уходит в эффекте. Второе: слушатель сравнивает имя таблицы, то есть перезапрос случится на любую запись в эту таблицу, даже если строка к запросу не относится. Повторим ту же форму миниатюрой над своим файлом. На диске уже лежит один подход, а через 120 мс появится второй. Прогноз: какие кадры напечатаются и что будет в updatedAt?
Первый кадр снова соврал «у вас ещё нет тренировок», хотя подход в базе есть. Это та же ложь, с которой начиналась статья «Первый кадр: пока хранилище читается», и причина та же: экран нарисовался раньше, чем пришёл ответ. Только теперь ждать приходится не диску целиком, а одному запросу, и различить «пусто» и «ещё не читали» можно прямо по возвращённому значению: пока ответа не было, updatedAt равен undefined. У настоящего useLiveQuery ровно так же — видно по исходнику выше.
Третий кадр — то, ради чего живой снимок и нужен: подход записан из другого места программы, никто ничего не перечитывал руками, а лента обновилась. Разница с хранилищем первой статьи в том, кто рассказал: там — ваш write, здесь — база. Поэтому изменение, пришедшее с сервера и записанное фоновой синхронизацией, доедет до экрана тем же путём, без единой строки в экране.
Что до трёх состояний экрана из части IV, они здесь по-прежнему не нужны: error у useLiveQuery — это ошибка запроса, а не «не удалось загрузить». А заботиться о пустом первом кадре надо в двух местах сразу: SQLiteProvider с useSuspense держит экран до открытия базы, но не до первого ответа запроса — за него отвечает updatedAt.
Мелочи вроде курсора синхронизации таблицей не оформляют, и ради них не обязательно держать второй пакет. У expo-sqlite есть kv-store: тот же интерфейс, что у AsyncStorage, но поверх той же SQLite — в исходнике он так и назван заменой один в один. Внутри у него, что приятно, ровно та лестница, которую вы писали руками, вплоть до PRAGMA user_version:
А вот токен синхронизации в базу не кладут. База — обычный файл в папке приложения; на устройстве с доступом к файловой системе это не секрет. Для секретов у системы своё хранилище — Keychain на iOS, Keystore на Android, — и к нему ходит expo-secure-store. У него та же история с платформами: веб-сборка пакета — пустой объект, поэтому в браузере вызовы упали бы, и пара файлов по расширению здесь не украшение, а единственный способ:
Так в дневнике оказывается три разных хранилища, и это не беспорядок, а разделение по сроку и по секретности: база — дневник и очередь, kv-store — мелочи вроде курсора, SecureStore — токен. Ни одно из них не заменяет другое. Мелочи мог бы держать и react-native-mmkv — хранилище «ключ — значение» на собственном нативном модуле, но ради него нужна своя сборка приложения; отчего так, видно на полке React Native и Expo.
На этом глава кончается: дневник живёт на устройстве целиком, переживает перезапуск, смену схемы и первый кадр, а интерфейс JournalDb одинаков на телефоне и в браузере. В главе 7 у outbox() появится адресат: очередь уедет пачкой на сервер, сервер вернёт чужие операции, и выяснится, что самое трудное в синхронизации — не отправить, а не задвоить.
- Настоящий expo-sqlite в песочнице не запустится по двум причинам: модуль Expo импортирует
TurboModuleRegistry, которого нет в react-native-web, а его веб-сборка ждёт ответ Worker черезSharedArrayBuffer— имя, которое браузер объявляет только на странице с кросс-источниковой изоляцией (crossOriginIsolated = falseв выводе). - Собранное за главу находит имена:
runAsyncиgetAllAsyncвместо переписывания всего журнала,withTransactionAsyncвместо «одним шагом»,PRAGMA user_versionвместо номера схемы в значении,onInitпровайдера вместо лестницы вboot. - Таблиц две:
setsсо строками иopsс журналом. Строки больше не собираются свёрткой при запуске, но журнал остаётся: из строк не видно, что ещё не отправлено. Шаг таблицы —ALTER TABLE, шаг журнала — по-прежнему цикл, потому что поля правки лежат строкой JSON. - Один интерфейс, два файла:
journal-db.tsдля телефона иjournal-db.web.tsповерх ящика — сборщик выбирает по расширению, и сценарий печатает те же строки, что печатал ящик. Чтение остаётся асинхронным нарочно, ради телефона. - Живой снимок — это событие базы:
enableChangeListenerпри открытии плюсaddDatabaseChangeListener, поверх которых работаетuseLiveQueryиз drizzle-orm. Его первый кадр пуст, потому что запрос уходит в эффекте; «пусто» от «ещё не читали» отличаетupdatedAt. - Три хранилища вместо одного: база — дневник и очередь,
expo-sqlite/kv-store— мелочи вроде курсора (замена AsyncStorage один в один со своей лестницей наuser_version), SecureStore — токен, потому что файл базы секретом не является.
1/4Почему настоящий expo-sqlite не выполнится в песочнице, даже если положить пакет рядом?
2/4Пример напечатал «SharedArrayBuffer: undefined», а компилятор об этом имени промолчал. Почему?
3/4Что в expo-sqlite соответствует правилу «строка и операция пишутся одним шагом»?
4/4useLiveQuery вернул data: [] и updatedAt: undefined. Что это значит?
Транзакция, которой не было
Веб-файл написали в лоб: сначала записать строку, чтобы лента обновилась сразу, потом дописать операцию. Процесс убили между двумя записями — рубильник crash из статьи про журнал изображает это исключением. Почините так, чтобы от неудачной записи не осталось ни строки, ни операции.
Всё, что может упасть, — до первой записи, а сама запись одна:
Вывод становится строк: 0 | в очереди: 0: сбой не оставил ничего, человек увидел ошибку и запишет подход заново. В исходном коде строка легла на диск, а операция — нет, и это ровно та беда из статьи «Журнал: имена и номера рождаются на телефоне», только теперь она переживает перезапуск: подход навсегда виден в ленте и навсегда неизвестен серверу.
На телефоне записи две — две вставки в две таблицы, — и одним шагом их делает withTransactionAsync: при исключении внутри неё откатываются обе. Порядок вызовов при этом ничего не решает, потому что процесс убивают не по вашему плану.
Экран тренировки застрял на прошлой
Экран тренировки читает подходы своим запросом: workoutId приходит пропсом, фильтр внутри запроса. Пока тренировка одна, всё хорошо; при переходе на следующую экран показывает подходы предыдущей и больше не меняется. Почините, не трогая ни хранилище, ни хук.
Запрос зависит от workoutId, значит, его надо назвать зависимостью — третьим параметром хука, как у настоящего useLiveQuery:
Появляется четвёртая строка: тренировка w2: A-2. Без неё эффект с пустым списком зависимостей выполнялся один раз, и запрос навсегда оставался запросом первой тренировки: новый workoutId попадал только в стрелку, которую никто больше не звал.
Третья строка, тренировка w2: A-1, остаётся и в починенном виде, и это не ошибка: кадр с новым пропсом рисуется сразу, а ответ базы приходит позже, и в промежутке в data лежат подходы прошлой тренировки. У настоящего useLiveQuery точно так же — data не обнуляется при смене зависимостей. Если мелькание чужих подходов недопустимо, различить старый ответ от нового помогает updatedAt.