Перейти к содержанию

Схема IndexedDB для записей и очереди

В предыдущем уроке мы разделили текст заметки и обязательство отправить изменение. Теперь выразим эту модель в IndexedDB. У «Блокнота» будет одна база на учебный аккаунт и три object store: notes, outbox, meta. После этой главы вы сможете объяснить, почему создание хранилищ происходит при обновлении версии и почему открытая старая вкладка иногда мешает такому обновлению.

Конечный файл public/db.js включён в архив. Листинг ниже показывает полный механизм открытия базы из этого файла; остальная программа развивается дальше. Автор не открывал базу в браузере и не выполнял сценарии миграции. Описания событий и состояния объектов — ожидаемые результаты, сверенные с руководством MDN по IndexedDB.

Object store сохраняет прикладные объекты

IndexedDB хранит структурированные объекты, ключи и индексы. В notes ключом станет поле id, в outbox — noteId, в meta — key. Такое разделение соответствует разным операциям: список читает заметки, отправитель читает очередь, получение серверных изменений читает cursor. Нам не нужно объединять всё в одну строку JSON и переписывать всю коллекцию при изменении заголовка.

Ключ outbox выбран намеренно: на одну заметку допускается один текущий снимок отправки. Это не универсальное ограничение IndexedDB, а решение нашего протокола. Если бы очередь сохраняла произвольное число операций, её первичным ключом мог бы быть opId, а noteId стал бы индексом. Сначала выбирают инвариант приложения, затем структуру хранилища, которая помогает его поддерживать.

Хранилище meta нужно для маленьких служебных записей, в частности {key:'cursor', value:0}. Cursor обозначает последнее полученное место серверного журнала. Он не является версией базы IndexedDB. Повышение номера схемы браузерной базы и продвижение server cursor решают разные задачи; одинаковое слово «версия» не должно скрывать это различие.

Открытие и создание схемы

Указание версии 1 вызывает onupgradeneeded при первом создании базы. Внутри этого события браузер разрешает менять структуру. При следующих открытиях уже существующей первой версии хранилища не создаются заново и записи не очищаются. Имя содержит подтверждённый аккаунт; вопрос доверия к этому имени подробно рассмотрим в главе об авторизации.

export function openDB(account) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(`bloknot:${account}`, 1);
    request.onupgradeneeded = () => {
      const db = request.result;
      db.createObjectStore('notes', { keyPath: 'id' });
      db.createObjectStore('outbox', { keyPath: 'noteId' });
      db.createObjectStore('meta', { keyPath: 'key' });
    };
    request.onerror = () => reject(request.error);
    request.onblocked = () => reject(new Error('Закройте старую вкладку Блокнота'));
    request.onsuccess = () => {
      const db = request.result;
      db.onversionchange = () => db.close();
      resolve(db);
    };
  });
}

Promise оборачивает событийный API, чтобы вызывающий код мог дождаться открытия. Здесь resolve относится к получению соединения, а не к сохранению заметки. Обработчик error передаёт ошибку наружу; приложение не должно изображать пустой исправный список после отказа открытия. Такое пустое состояние могло бы заставить человека решить, что его прежние записи исчезли.

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

Почему нельзя создавать store при каждом сохранении

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

Допустим, в следующей версии продукта потребуется индекс заголовка. Тогда повышают номер схемы и внутри upgrade проверяют oldVersion. Первая база получает исходные stores и новый индекс, прежняя база получает только недостающее изменение. Конечная лаборатория остаётся на первой версии: в архиве нет фиктивного upgrade, который мог бы уничтожить уже созданные данные читателя.

Миграция структуры не должна требовать сетевого ответа внутри versionchange транзакции. Сервер может быть недоступен, а жизнь IndexedDB транзакции привязана к выполнению её запросов. Если понадобятся тяжёлое преобразование или получение данных, их лучше проектировать отдельным возобновляемым этапом с отметкой прогресса. Одно повышение номера не даёт автоматической устойчивости произвольному процессу миграции.

Соединение и транзакция имеют разную длительность

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

Для чтения списка достаточно readonly. Для одновременного сохранения заметки и outbox потребуется readwrite с двумя именами stores. Scope транзакции заранее определяет доступные хранилища. Если функция не включает meta, она не должна внезапно менять cursor. Это помогает читать код: ответственность видна уже в месте создания транзакции, до анализа всех обработчиков событий.

Успех отдельного put не равен успешному завершению всех изменений. После него другой запрос может привести к отмене всей транзакции. Поэтому сообщение «Сохранено локально» будет появляться после complete. Подробно разберём эту границу в следующей главе. Уже сейчас следует избегать Promise, который разрешается при первом request.onsuccess и забывает о возможном abort.

Читаем пустую и существующую базу

В будущей самостоятельной работе первое открытие Alice даст пустой список: это нормальное состояние новой базы. После сохранения «Плана урока» и перезагрузки страницы getAll прочитает объект с тем же id. Изменение заголовка не должно создавать второй ключ, а открытие Bob даст другую базу. Такой сценарий проверяет выбранную модель разделения, не предполагая серверной синхронизации.

База другого профиля браузера тоже начнёт пустой, хотя имя аккаунта и адрес совпадают. IndexedDB не переносится между профилями сама. Чтобы получить прежний текст, второй профиль обратится к серверному журналу. Если первая копия ещё не отправила заметку, сервер не сможет отдать её второй. Это ожидаемая причинная связь между локальным хранением и дальнейшим протоколом доставки.

Ошибочный вариант — удалить базу при любом затруднении открытия. Такой «ремонт» уничтожает неотправленные заметки и очередь. Сначала выясняют причину: закрытое соединение, заблокированный upgrade, ограничения режима браузера или место на диске. Если нужно восстановление, человек сначала получает возможность экспортировать доступные данные. Очистка не является стандартным началом работы приложения.

Теперь у модели есть конкретное место хранения и понятный жизненный цикл. Схема создаётся один раз, старые соединения уступают обновлению, операции используют короткие транзакции. На следующем этапе применим эти свойства к сохранению формы, чтобы notes и outbox не расходились при закрытии вкладки.

Оглавление серии