Восстановление повреждённого кеша
Сохранённая глава вчера открывалась вместе со схемой, а сегодня изображение исчезло. Сообщение «готово» в интерфейсе ещё может оставаться: оно описывало завершение загрузки, а не проверку каждого ресурса сейчас. Читателю нужен способ понять состояние копии и восстановить её, сохранив свои закладки.
В этом уроке вы добавите отдельный диагностический модуль к offline-lab. Он использует пакет из девятой главы: кеш pw-package-v1-…, описатель /__offline-ready.json и указатель в pw-reader-state. Сетевое получение, работающая оболочка и данные IndexedDB остаются отдельными частями приложения.
Сначала различаем состояния
Пакет выпуска v1 не становится повреждённым только потому, что сервер уже предлагает v2. Он может содержать весь свой HTML, CSS и SVG и правильно показывать прежнее содержание. Здесь нужны предложение обновления и обозначение версии. Повторная загрузка требуется по другой причине, чем исправление недостающего файла.
Также отличайте отсутствие сети от неполноты кеша. Если готовая копия открывается, а проверка свежести не удаётся, последнее действие не опровергает существование копии. Обратная ситуация тоже возможна: сервер доступен и скрывает проблему, подставляя потерянную картинку из сети. Один успешный просмотр онлайн не доказывает автономность.
И наконец, весь кеш пакета может отсутствовать. При таком состоянии caches.open(name) создаст пустой кеш, а не вернёт ошибку «не найден». Поэтому перед диагностическим открытием используем caches.has(name). Проверка существования CacheStorage. Между двумя операциями состояние всё равно способно измениться: это наблюдение, не блокировка хранения.
Проверяем описатель и обязательный набор
Создайте полный assets/cache-recovery.js. Модуль работает в странице своего учебного origin. Он не делает сетевых запросов и не пытается проверить произвольный чужой URL:
const PACKAGE = /^pw-package-v\d+-[0-9a-f-]{36}$/;
const STATE = "pw-reader-state";
const SELECTED = "/__offline-selection.json";
const READY = "/__offline-ready.json";
const TYPES = new Set(["text/html", "text/css", "image/svg+xml"]);
function ownedName(name) {
if (typeof name !== "string" || !PACKAGE.test(name)) {
throw new TypeError("Неизвестное имя пакета");
}
}
export async function inspectPackage(name) {
ownedName(name);
if (!(await caches.has(name))) return {state: "absent"};
const cache = await caches.open(name);
const ready = await cache.match(READY);
if (!ready) return {state: "incomplete", reason: "Нет описателя"};
let plan;
try { plan = await ready.json(); }
catch { return {state: "invalid", reason: "Описатель не является JSON"}; }
if (!plan || !/^v\d+$/.test(plan.release_id) || plan.article_id !== "xml" ||
!name.startsWith("pw-package-" + plan.release_id + "-") ||
!Array.isArray(plan.resources) || plan.resources.length !== 3) {
return {state: "invalid", reason: "Неверная схема XML-пакета"};
}
const expected = new Map([
["/my/LINQ/linq_xml/level7/7_1.php", "text/html"],
["/assets/site." + plan.release_id + ".css", "text/css"],
["/assets/xml." + plan.release_id + ".svg", "image/svg+xml"]
]);
const problems = [];
const urls = new Set();
for (const item of plan.resources) {
if (!item || typeof item.url !== "string" || !TYPES.has(item.type)) {
return {state: "invalid", reason: "Неверный ресурс"};
}
let url;
try { url = new URL(item.url, location.origin); }
catch { return {state: "invalid", reason: "Неверный URL"}; }
if (url.origin !== location.origin || url.search || url.hash || urls.has(url.href) ||
expected.get(url.pathname) !== item.type) {
return {state: "invalid", reason: "Адрес вне договора или повтор"};
}
urls.add(url.href);
const response = await cache.match(url.href);
if (!response || !response.ok) {
problems.push({url: item.url, reason: "Нет успешного ответа"});
continue;
}
if (!(response.headers.get("content-type") ?? "").includes(item.type)) {
problems.push({url: item.url, reason: "Другой тип содержимого"});
}
if (item.type === "text/html") {
const marker = 'name="pw-release" content="' + plan.release_id + '"';
if (!(await response.text()).includes(marker)) {
problems.push({url: item.url, reason: "HTML другого выпуска"});
}
}
}
return {state: problems.length ? "incomplete" : "complete",
release_id: plan.release_id, problems};
}
export async function forgetPackage(name) {
ownedName(name);
let detached = false;
if (await caches.has(STATE)) {
const state = await caches.open(STATE);
const pointer = await state.match(SELECTED);
if (pointer) {
let selection;
try { selection = await pointer.json(); }
catch { throw new Error("Указатель повреждён; нужен отдельный сброс выбора"); }
if (selection?.cacheName === name) detached = await state.delete(SELECTED);
}
}
return {detached, removed: await caches.delete(name)};
}
Функция возвращает различимые результаты: absent, invalid, incomplete, complete. Ошибка чтения самого хранилища остаётся отклонением Promise и обрабатывается интерфейсом отдельно. Нельзя любое исключение переименовывать в «повреждённая статья»: например, доступ к хранению может быть запрещён.
Это проверка конкретного XML-пакета из трёх ресурсов. Она сверяет записанный список, успешные ответы, типы и метку HTML. Она не проверяет криптографические хеши, правильность изображения или полный граф зависимостей. Для другого набора нужно расширить договор и проверку, а не просто убрать ограничение количества ресурсов.
Восстанавливаем через новый кандидат
Представим, что в описателе перечислена /assets/xml.v1.svg, но cache.match не возвращает её ответ. Ожидается incomplete с адресом в problems. Такой результат сообщает, что нарушено условие обязательного набора; он не объясняет, кто именно удалил файл. Диагностику причины нужно вести отдельно.
Для восстановления загрузите согласованный план текущего выпуска и вызовите preparePackage(plan) из девятого урока. Сначала появится новый уникальный кандидат. Только после получения всех обязательных ответов изменится указатель. Отказ во время подготовки не должен уничтожать старую копию, которую ещё можно читать частично.
Не пытайтесь «долечить» сохранённый HTML случайным CSS текущей версии. Если HTML относится к v1, а сервер уже изменил схему документа, сочетание с v2 может оказаться неверным. Новый полный пакет проще объяснить и проверить, чем набор незаметных замен внутри прежнего кеша.
Восстановление требует доступного источника ресурсов. Если сети нет, приложение может показать сохранённый текст и назвать отсутствующую схему, но не способно создать её из описателя. Поэтому рядом с состоянием полезны разные действия: повторить получение позже, открыть доступный текст, удалить ненужную копию.
Удаляем только выбранную копию
forgetPackage принимает точное имя учебного пакета. Если он выбран, сначала удаляется соответствующий указатель, затем сам кеш. Остальные пакеты, кеш оболочки pw-offline-v3 и база pw-reader не затрагиваются. Метод CacheStorage.delete возвращает false, когда именованного кеша уже нет; это не обязательно новая ошибка. Удаление одного кеша.
Две операции не являются общей транзакцией. При остановке между ними может остаться невыбранный кеш. Повторное удаление допускает такое состояние. При одновременном сохранении другая вкладка способна поменять указатель, поэтому учебный сброс выполняйте после остановки загрузок и закрытия других вкладок приложения. Функция сама не реализует межвкладочную блокировку.
Если повреждён JSON самого указателя, приведённая функция останавливается с отдельным сообщением. После тех же условий остановки можно адресно удалить /__offline-selection.json из pw-reader-state. Это сброс выбора, а не удаление всех кешей. Worker должен переходить к сетевой ветке и оболочке при ошибке чтения служебного указателя, как предусмотрено в обработчике девятой главы.
Кнопка ремонта не должна автоматически выполнять indexedDB.deleteDatabase("pw-reader") или очистку всего origin. Закладки являются пользовательскими данными, тогда как HTML можно получить заново. Даже если они хранятся в одном браузере, у них различная ценность и различный путь восстановления.
Видимое сообщение должно называть действие точнее, чем «Исправлено». Например, после подготовки можно написать: «Сохранён новый XML-пакет v2; для просмотра откройте главу заново». После удаления: «Локальная копия удалена; закладки не изменялись». Это разные результаты, и ни один не означает проверку всего приложения.
Если диагностический модуль получил complete, сохраните рядом время этого наблюдения. Не превращайте его в постоянный значок гарантии: другая вкладка может изменить данные после проверки. Для повторного открытия удобно снова читать описатель и состояние нужного пакета. Такое чтение не заменяет проверку каждой зависимости в сложной библиотеке, но связывает сообщение интерфейса с конкретной копией и понятным моментом.
Наблюдение после ремонта
Для будущего сценария запишите имя пакета, выпуск, отсутствующий адрес и итог подготовки. После успешной замены потребуется новая навигация; уже открытая статья сама не станет новой версией. Учтите поведение нескольких вкладок из предыдущего урока и ресурсы, необходимые их прежнему HTML.
Отдельно рассмотрите отсутствие пакета целиком и старый, но полный выпуск. В первом случае интерфейс предлагает загрузку, во втором — обновление по выбору. Отсутствие сети не должно автоматически удалять ни тот, ни другой набор. Такие различия сохраняют читаемую часть вместо универсальной команды «сбросить всё».
Теперь восстановление имеет локальную цель и объяснимую причину. Остался вопрос более широкого масштаба: почему браузер вообще может отказать в записи или удалить данные. В последней главе разберём квоты, оценку места и ограниченную защиту хранения.