Атомарное сохранение заметки и outbox
«Блокнот» уже умеет открыть три хранилища. Теперь нужно связать кнопку формы с двумя изменениями: обновлением notes и появлением операции в outbox. Результат урока — локальное сохранение, которое не оставляет текст без обязательства доставки. Для пользователя это выражается простым сообщением после завершения транзакции, для программы — согласованным состоянием двух stores.
Будем продолжать заметку «План урока». Полная реализация находится в public/db.js, а форма — в public/app.js архива. Сервер и браузер автором не запускались; закрытие вкладки и ошибки сохранения ниже рассматриваются как ожидаемые сценарии. Свойства транзакций сверены с документацией MDN.
Две независимые записи оставляют промежуток
Представьте функцию, которая сначала сохраняет notes, а потом создаёт отдельную транзакцию outbox. В нормальной работе оба вызова успеют завершиться, поэтому ошибка долго остаётся незаметной. Однако вкладка может закрыться между ними, второй запрос может превысить квоту, а код может выбросить исключение. Тогда заметка уже есть, но будущий отправитель не увидит причины отправить её на сервер.
Повторное открытие страницы не исправит это автоматически. Отправитель читает очередь, а не сравнивает каждую локальную запись со всеми серверными данными. Попытка искать «что, вероятно, не отправлено» по времени изменения ненадёжна: часы устройств отличаются, записи могут быть импортированы, а подтверждение может потеряться. Поэтому сохранение должно создавать явное согласованное состояние сразу.
Список имён при db.transaction задаёт общий scope. Readwrite транзакция охватывает notes и outbox, даже если изменения используют разные ключи. Если она завершится, новый текст и соответствующее намерение станут доступны вместе. Если она будет отменена, прежнее состояние сохранится. Это атомарность локальной операции, а не распределённая транзакция между браузером и сервером.
Обёртка ждёт завершения всего набора
В db.js используется небольшая обёртка. Callback синхронно планирует запросы, а его done сохраняет будущий результат. Promise разрешается обработчиком complete. Поэтому значение может быть подготовлено раньше, но вызывающая форма ещё не получает успешный ответ.
export function transaction(db, names, mode, callback) {
return new Promise((resolve, reject) => {
const tx = db.transaction(names, mode);
let result;
const stores = Object.fromEntries(names.map(name => [name, tx.objectStore(name)]));
tx.oncomplete = () => resolve(result);
tx.onabort = () => reject(tx.error || new Error('Транзакция отменена'));
tx.onerror = () => {};
try { callback(stores, value => { result = value; }, tx); }
catch (error) { tx.abort(); reject(error); }
});
}
Обработчик abort важен даже при отсутствии request.onerror в каждом фрагменте. Ошибка запроса обычно приводит к отмене транзакции; именно общий отказ должен попасть в форму. Пустой tx.onerror не отменяет стандартную реакцию через preventDefault. Если callback бросает исключение при планировании, обёртка явно отменяет транзакцию и возвращает причину.
В этой функции нельзя выполнять сетевой await. Во время ожидания HTTP активная транзакция может завершиться, а новый запрос к store уже окажется запоздавшим. Сеть находится снаружи: сначала читается готовый снимок, затем отправляется запрос, после ответа создаётся новая короткая транзакция подтверждения. Так длительность неизвестного соединения не становится длительностью локального изменения.
Изменяем прежний объект по постоянному ключу
SaveNote сначала читает notes по id. Для новой заметки прежнего значения нет, поэтому revision берётся нулевой, localVersion становится единицей. Для существующей заметки сохраняются подтверждённая revision и конфликт, если он уже обнаружен. Заголовок и body берутся из формы; id при переименовании не меняется.
Следующий фрагмент показывает существенную часть обработчика уже прочитанной заметки, а не отдельную программу. Функция operation и транзакционная обёртка определены в конечном db.js.
const note = {
id, title, body, deleted,
revision: old?.revision || 0,
localVersion: (old?.localVersion || 0) + 1,
conflict: old?.conflict || null
};
s.notes.put(note);
s.outbox.get(id).onsuccess = event => {
if (!event.target.result && !note.conflict) s.outbox.put(operation(note));
};
done(note);
Существующая операция не перезаписывается. Она может уже путешествовать по сети, поэтому другой body под тем же opId разрушил бы правило повторной доставки. Последняя локальная редакция остаётся в notes. Когда прежняя операция будет подтверждена, приложение сравнит localVersion и создаст следующую операцию при необходимости. До этого момента одна заметка имеет один текущий снимок очереди.
Если заметка заблокирована конфликтом, обычное сохранение всё равно обновляет локальный текст, но не снимает конфликт. Пользователь может спокойно составлять объединённую редакцию. Чтобы разрешить её отправку, понадобится отдельное явно выбранное решение. Тем самым кнопка «Сохранить локально» не получает скрытой ответственности переписать чужую серверную работу.
Успех формы и отказ сохранения
В app.js обработчик submit отменяет обычную отправку HTML формы, вызывает await saveNote и только затем сообщает «Сохранено локально». После этого он предлагает другим вкладкам перечитать базу и запускает синхронизацию. Эти действия могут закончиться позже или не состояться, но уже завершённое локальное сохранение от них не зависит.
Ожидаемый результат для новой записи — она появляется в списке с подписью ожидания отправки. Повторное сохранение того же id изменяет текст и localVersion, не создавая ещё одну строку списка. Если сервер недоступен, сообщение о сети может сменить общую строку статуса, однако сохранённый объект остаётся в IndexedDB. Нельзя показывать сетевую ошибку как доказательство потери локального текста.
При ошибке readwrite транзакции форму не очищают. Человек ещё видит введённый текст и может скопировать его, освободить место или повторить сохранение после устранения причины. Если очистить поля сразу при нажатии кнопки, отказ квоты оставит пользователя без нового текста и без сохранённого объекта. Поэтому изменение интерфейса привязывается к результату, а не к началу действия.
В конечном примере дополнительно проверяется expectedVersion формы. Если другая вкладка успела сохранить более позднюю локальную редакцию, транзакция отменяется. Это не серверный конфликт revision, а защита от сохранения устаревшего экрана поверх общей браузерной базы. Дальше разберём её отдельно; пока достаточно понимать, почему даже одна кнопка может получить полезный отказ.
Что гарантирует завершённая транзакция
Complete обозначает успешное завершение IndexedDB операции по правилам браузера. Не следует автоматически превращать это событие в обещание физической записи всех байтов на диск при любой аварии питания. Политика durability и реализация браузера имеют собственные особенности. Для учебного приложения важно честно назвать результат локальным сохранением, а для критичных данных дополнительно проектировать внешние копии.
Отмена транзакции также не означает удаление всей базы. Она возвращает изменения конкретного набора запросов к прежнему состоянию. Поэтому в обработчике ошибки не вызывают deleteDatabase и не очищают очередь «для порядка». Такое исправление уничтожило бы независимые заметки, к которым текущая ошибка отношения не имеет.
После этого урока «План урока» и обязательство его отправки появляются согласованно. В обычном проходе пользователь продолжает работу сразу после complete; отправитель позже получает уже существующее намерение. Следующий шаг посвящён самому снимку: определим его идентификатор, неизменяемые поля и поведение при повторе после потерянного ответа.