Миграция локальных данных
Локальные закладки уже отделены от загружаемых страниц. Теперь читатель хочет сохранить настройку отображения текста. Добавим object store settings, не удаляя существующие bookmarks. Это небольшая миграция, на которой удобно объяснить версии базы, блокировку соединений и различие между структурой данных и выпуском приложения.
База по-прежнему называется pw-reader. Версия1 содержала bookmarks с keyPath article_id. Версия2 добавляет settings с keyPath key. Значения прежних закладок менять не требуется. Простая миграция особенно полезна для урока: она показывает, что новая функция может расширить схему без бессмысленного переписывания всех записей.
Когда меняется структура
Число версии передаётся indexedDB.open. Если запрошенная версия выше существующей, браузер запускает upgrade-процесс. Создание нового object store выполняется в обработчике upgradeneeded. Обычная транзакция записи после открытия не предназначена для изменения самой структуры базы. IDBFactory.open.
Новый профиль сразу создаёт схему версии2. Профиль с версией1 выполняет только недостающий шаг. Поэтому сравниваем oldVersion отдельно для каждого изменения. Такой порядок делает последовательность явной и позволяет позже добавить версию3 без специального удаления прежней базы.
Ниже полная замена функции openReader из предыдущего урока. Функции saveBookmark и readBookmark сохраняются: они работают с тем же store. Вызывающая сторона получает callback для блокировки и закрытия устаревшего соединения. Новый модуль не скрывает эти состояния за бесконечным ожиданием без сообщения.
export function openReader(onStatus = console.warn) {
return new Promise((resolve, reject) => {
const request = indexedDB.open("pw-reader", 2);
request.onupgradeneeded = event => {
const db = request.result;
if (event.oldVersion < 1) {
db.createObjectStore("bookmarks", {keyPath: "article_id"});
}
if (event.oldVersion < 2) {
const settings = db.createObjectStore("settings", {keyPath: "key"});
settings.put({key: "preferences", text_size: "normal"});
}
};
request.onerror = () => reject(request.error);
request.onblocked = () => {
onStatus("Обновление данных ожидает закрытия прежней вкладки");
};
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => {
db.close();
onStatus("Схема данных обновляется; откройте учебник заново");
};
resolve(db);
};
});
}
export function savePreferences(db, textSize) {
if (!["normal", "large"].includes(textSize)) {
return Promise.reject(new Error("Неизвестный размер текста"));
}
return new Promise((resolve, reject) => {
const tx = db.transaction("settings", "readwrite");
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error ?? new Error("Настройка не записана"));
tx.onerror = () => reject(tx.error);
tx.objectStore("settings").put({key: "preferences", text_size: textSize});
});
}
Ожидаемый результат для существующей версии1: закладка xml остаётся, появляется preferences с normal. Для нового профиля оба store создаются в одном upgrade. Значение normal — исходное решение продукта, а не результат наблюдения за предпочтениями человека. Пользователь затем может изменить его на large явным действием.
Обработчик не вызывает clear для bookmarks и не пересоздаёт store с тем же именем. Его отсутствие в ветке второго шага — конкретное свойство миграции, а не предположение об автоматическом копировании удалённых данных. Если бы мы сначала удалили store, браузер не восстановил бы записи только потому, что новый получил похожее имя.
Отказ upgrade-транзакции должен попасть в request.onerror. В такой ситуации интерфейс не сообщает о готовой схеме2 и не начинает обращаться к settings, которого может не существовать в завершённом состоянии. Повторная попытка открытия рассматривается отдельно после выяснения причины. Удаление базы не становится универсальной обработкой ошибки.
При изменении настройки значение large сначала проходит разрешённый список, затем записывается в транзакции. Внешний вид страницы можно менять после успешного завершения, а при отказе объяснить, что выбор пока действует только в текущем интерфейсе. Так сохраняется различие между временным состоянием DOM и постоянными данными пользователя.
Структурные операции запускаются синхронно внутри обработчика. Здесь нет fetch и нет ожидания удалённого ответа. Независимая сетевая операция может закончиться уже после того, как транзакция перестала быть активной. Поэтому данные для будущего сложного преобразования готовятся заранее, а изменение структуры связывается с работой самой upgrade-транзакции. Использование IndexedDB.
Другая открытая вкладка
Старое соединение способно мешать обновлению версии. Событие blocked сообщает о таком ожидании, а versionchange даёт прежней вкладке возможность закрыть своё соединение. Закрытие соединения не равно автоматическому перезапуску страницы. Интерфейс должен перестать отправлять новые операции в закрытый db и предложить понятное обновление состояния.
Обработчик одиннадцатого урока уже закрывает соединение при versionchange. В новом варианте добавлено сообщение. Если старый код не реагирует на событие, человек может закрыть прежнюю вкладку вручную. Не удаляйте базу, чтобы обойти блокировку: это уничтожило бы данные из-за обычного состояния нескольких окон.
Пока blocked не завершился открытием или ошибкой, Promise остаётся ожидающим. Сообщение должно быть видимым, а действие сохранения — недоступным. Таймер интерфейса может напомнить о необходимости закрыть окна, но не способен сам подтвердить успех миграции. Он также не должен объявлять окончательный отказ только потому, что пользователь ещё не успел закрыть вкладку.
Совместимость прежнего кода
После перехода базы на версию2 попытка открыть её с явно запрошенной версией1 может завершиться VersionError. Поэтому старый код не должен считать, что его привычный номер подходит всегда. В нашей учебной ветке заменяется openReader во всём текущем приложении; старые открытые страницы закрывают соединение и получают объяснение обновления.
Более сложный продукт может поддерживать чтение нескольких схем, но это отдельный договор совместимости. Нельзя просто убрать номер версии из всех вызовов и предположить, что любой будущий store имеет прежний формат. Код обязан проверить, с какой структурой работает, и иметь ясную политику старого клиента.
Новая база не требует нового URL XML. Номер схемы2, кеш оболочки v3 и release_id статьи v1 описывают три разных объекта. Изменение settings не означает, что XML переписан; новый worker не означает, что нужно заново создавать bookmarks. Такое различие предотвращает лишние сбросы при обычном развитии интерфейса.
Подготовка следующего преобразования
Если позже понадобится переименовать поле закладки, заранее опишите старую и новую форму. Решите, какие неизвестные значения сохраняются, что делать с некорректной записью и как сообщать о частичной невозможности чтения. Формат не меняется только потому, что разработчик переименовал локальную переменную в app.js.
В текущем переходе мы намеренно не заполняем старым закладкам придуманное release_id. Если поле отсутствует, читатель функции может трактовать его как unknown. Автоматический перевод всех прежних записей в текущий выпуск создал бы ложную историю. Миграция сохраняет смысл данных, а не только заставляет новую программу перестать выдавать ошибку.
Матрица будущих наблюдений дополняется новым профилем, существующей версией1, открытой старой вкладкой и отказом операции. Поля expected и observed остаются раздельными. Успешный compile исходника сам по себе не подтвердил бы ни сохранность записи, ни поведение blocked в конкретном браузере.
Теперь локальная схема развивается постепенно. Закладки остаются пользовательскими данными, настройки получают отдельный store, а кеши статей продолжают жить независимо. Следующий урок вернётся к загрузке пакетов и разберёт прерывание операции, не превращая частичный набор в готовый офлайн-учебник.