diff --git a/editorial/production/README.md b/editorial/production/README.md index bcbf0fb..5bb43f5 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 160 из 358 созданных материалов. Остальные 198 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 163 из 358 созданных материалов. Остальные 195 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2022-07-draft.md b/editorial/reviews/2022-07-draft.md new file mode 100644 index 0000000..8aff5b1 --- /dev/null +++ b/editorial/reviews/2022-07-draft.md @@ -0,0 +1,69 @@ +# П53 · 2022-07 · Работа при плохой сети — три прохода саморевью + +## Рамка пакета + +- Slug: `editorial-2022-07-practice-poor-network`, `editorial-2022-07-mechanism-poor-network`, `editorial-2022-07-field-poor-network`. +- Голос: М5, frontend-системщик 2022 года. Мост от ошибок формы и идемпотентности к пользовательскому сценарию: видимое состояние, владение черновиком, граница попытки и подтверждение результата. +- Граница компетентности: нет выдуманного production SLO, инцидента, offline trace, browser-прогона, настоящего Fetch/HTTP, service worker или ответа сервера. Network labels, keys и acknowledgement — учебные значения модели. +- Fixture: только локальные объекты и массивы. Она не создаёт DOM, storage API, worker, таймер или запрос. `unknown-outcome` означает отсутствие acknowledgement в модели, а не установленный факт о сети или сервере. + +## Проход 1 — факты и техника + +- Историческая рамка проверена по датированному [WHATWG Fetch Review Draft, 19.12.2021](https://fetch.spec.whatwg.org/review-drafts/2021-12/), [W3C Service Workers Candidate Recommendation Draft, 12.07.2022](https://www.w3.org/TR/2022/CRD-service-workers-20220712/) и нормативному [RFC 9110, июнь 2022](https://www.rfc-editor.org/rfc/rfc9110.html). Текущие mutable-версии документации не используются как доказательство состояния 2022 года. +- Убрана ложная цепочка «нет ответа → сервер ничего не сделал». В тексте остаётся только проверяемое утверждение: текущая модель не приняла acknowledgement с ожидаемыми ключами и версией. +- Fixture разделяет `draft.version`, persisted copy, `requestKey`, `attemptKey`, phase и acknowledgement. Retry сохраняет logical request key и меняет attempt key; stale/duplicate payload не делает второй commit; acknowledgement старой версии не очищает более новый draft. +- После финальной правки пройдены: `node --check web/scripts/upgrade-2022-07.mjs` — PASS; `node web/scripts/upgrade-2022-07.mjs --verify-fixture` — PASS 15/15; `npm run audit:draft -- scripts/upgrade-2022-07.mjs` — PASS. + +## Проход 2 — редактура и голос + +- Первые два абзаца каждой статьи называют наблюдаемый сбой и цену: ложный success, дублирование действия, потерянный текст и невозможность объяснить состояние пользователю. +- Речь строится по маршруту «симптом → причина → проверка → действие». Термины acknowledgement, request key, attempt key, stale reply и recovery привязаны к одной модели, а не используются как абстрактные признаки надёжности. +- В каждой статье есть таблица, figure с содержательными alt/caption, исполнимый JS fixture, нумерованный маршрут, ограничения и проверяемый следующий шаг. +- Размер основного текста проверяется самим скриптом: 8 276 / 9 358 / 9 319 знаков без списка источников. Все три текста попадают в целевой диапазон М5 8–10 тыс. и в обязательный диапазон 5–15 тыс. + +## Проход 3 — визуал и выпуск + +- Три SVG отвечают на разные вопросы: user-visible network state, contract request/attempt/acknowledgement и диагностика/recovery. В них нет `script`, `foreignObject`, внешних URL или `data:image`. +- Пройдены XML-проверка, safety scan без совпадений и Sharp-рендер всех SVG на ширине 375 px; на узком экране читаются названия состояний, ключевые ветки и нижняя граница модели. +- Sidecar меняет только пять разрешённых файлов. Registry, README, `articles.json`, очередь, QUALITY_STANDARD, `docs/`, Git и чужие sidecar-файлы не затрагиваются. Интеграция и публикация не выполнялись. + +## Независимый редакторский приём + +### Проход 1 — источники и переходы + +Проверены доступность и статус фиксированных источников: WHATWG Fetch Review +Draft декабря 2021 года, Service Workers Candidate Recommendation Draft 12 +июля 2022 года и RFC 9110 отвечают `200`. Статья использует их только для +исторической границы Fetch/HTTP/service worker; прикладные keys и +acknowledgement остаются решениями учебной модели. + +В fixture усилены три проверки переходов. Снимок persisted draft теперь +сохраняется сразу после offline block; stale acknowledgement сравнивает +phase/request/attempt/acknowledgement до и после обработки; принятие старой +версии сохраняет новый draft в отдельном снимке до duplicate branch. Это +исключает ложный PASS, когда последующее действие маскирует раннюю мутацию. +Ни Fetch, ни транспорт, ни сервер при этом не создаются. Fixture прошла +**15/15 assertions**. + +### Проход 2 — текст и полнота + +Перечитаны problem/cost, таблицы, исполнимые примеры, routes, rollback и +границы трёх материалов. `unknown-outcome` везде означает отсутствие +model acknowledgement, а не утверждение о судьбе серверного действия. Draft +audit подтвердил 8 276 / 9 358 / 9 319 знаков; import-safe проверка вернула +три revision-объекта без `date` и `author`. + +### Проход 3 — визуал и выпуск + +`xmllint` прошёл; safety scan не нашёл script, `foreignObject`, внешних URL +или `data:image`. Sharp-рендеры на 375 px просмотрены: схема видимых +состояний, retry contract и diagnosis/recovery читаемы и отделяют model ack +от ответа сервера. После подключения июля registry содержит 154 ревизии. +Строгий slug-audit прошёл с одной figure, таблицей и code example на статью; +production build успешно сгенерировал 374 страницы. Sidecar следующих месяцев +не затрагиваются. + +## Финальный вердикт + +П53 принята и интегрирована после трёх независимых проходов. В коммит войдут +только пять файлов июля и два точечных файла интеграции. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index e0a21bf..2a168da 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -49,6 +49,7 @@ import { revisions as march2022Revisions } from '../scripts/upgrade-2022-03.mjs' import { revisions as april2022Revisions } from '../scripts/upgrade-2022-04.mjs'; import { revisions as may2022Revisions } from '../scripts/upgrade-2022-05.mjs'; import { revisions as june2022Revisions } from '../scripts/upgrade-2022-06.mjs'; +import { revisions as july2022Revisions } from '../scripts/upgrade-2022-07.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -103,4 +104,5 @@ export const editorialRevisions = [ ...april2022Revisions, ...may2022Revisions, ...june2022Revisions, + ...july2022Revisions, ]; diff --git a/web/public/assets/editorial/2022/poor-network-diagnosis-recovery-2022.svg b/web/public/assets/editorial/2022/poor-network-diagnosis-recovery-2022.svg new file mode 100644 index 0000000..e3f11bd --- /dev/null +++ b/web/public/assets/editorial/2022/poor-network-diagnosis-recovery-2022.svg @@ -0,0 +1,17 @@ + diff --git a/web/public/assets/editorial/2022/poor-network-retry-contract-2022.svg b/web/public/assets/editorial/2022/poor-network-retry-contract-2022.svg new file mode 100644 index 0000000..32812f2 --- /dev/null +++ b/web/public/assets/editorial/2022/poor-network-retry-contract-2022.svg @@ -0,0 +1,14 @@ + diff --git a/web/public/assets/editorial/2022/poor-network-state-2022.svg b/web/public/assets/editorial/2022/poor-network-state-2022.svg new file mode 100644 index 0000000..453132c --- /dev/null +++ b/web/public/assets/editorial/2022/poor-network-state-2022.svg @@ -0,0 +1,18 @@ + diff --git a/web/scripts/upgrade-2022-07.mjs b/web/scripts/upgrade-2022-07.mjs new file mode 100644 index 0000000..44e2b0c --- /dev/null +++ b/web/scripts/upgrade-2022-07.mjs @@ -0,0 +1,552 @@ +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function paragraph(text) { + return '
' + text + '
'; +} + +function heading(text) { + return '' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '';
+}
+
+function figure(src, alt, caption) {
+ return 'offline и unknown-outcome — проектные labels, а не отчёт о реальной сети.'),
+ heading('Четыре состояния, которые нельзя склеивать'),
+ paragraph('У отправки есть как минимум четыре разных факта. Первый: текст черновика сохранён локально в выбранном хранилище. Второй: интерфейс сейчас считает сеть недоступной или доступной. Третий: конкретная попытка ожидает прикладное подтверждение. Четвёртый: серверное подтверждение с идентификатором предметной операции действительно принято клиентской логикой. Если заменить эти факты одним boolean isSaved, UI обязательно назовёт один из них другим.'),
+ dataTable(
+ 'Видимый контракт отправки черновика',
+ ['Состояние интерфейса', 'Что известно', 'Что нельзя утверждать', 'Действие'],
+ [
+ ['Черновик готов', 'есть сохранённая локальная копия и её версия', 'что сервер видел текст', 'дать редактировать и показать дату/версию локальной копии'],
+ ['Ожидаем подтверждение', 'создан request key и одна attempt key', 'что операция уже завершилась', 'не показывать success; защитить повторный клик'],
+ ['Результат неизвестен', 'для попытки нет прикладного acknowledgement', 'что операция не дошла до сервера', 'сохранить черновик, дать повторить или перейти к восстановлению'],
+ ['Подтверждено', 'получен payload с request key, версией и submission id', 'что более новый черновик можно удалить', 'очистить только подтверждённую версию, новый draft оставить'],
+ ],
+ ),
+ heading('Почему «запрос упал» не равно «ничего не произошло»'),
+ paragraph('Fetch Standard описывает request, response и network error, но у прикладного экрана другая задача: он должен решить, что показать человеку без домысла о чужой стороне. Между началом операции и сообщением UI могли существовать промежуточные границы, которых экран не видит. Поэтому здесь не используется фраза «сервер точно не сохранил». Вместо неё есть более узкое и проверяемое условие: текущая модель не получила acknowledgement, связанный с текущими requestKey и attemptKey.'),
+ paragraph('Это продолжает июньский разбор ошибок формы. Там важно было не перезаписать исправленное поле поздней ошибкой. Здесь важно не стереть текст и не объявить победу поздним или отсутствующим подтверждением. В обоих случаях источник проблемы — два разных состояния получают одно имя «сохранено». Версия черновика, попытка доставки и признанный результат должны жить рядом, но не заменять друг друга.'),
+ paragraph('Видимый текст должен следовать этой модели. «Черновик сохранён на устройстве» и «изменение подтверждено» — разные сообщения с разными условиями показа. Первое можно показать после успешного шага persistence выбранного приложения, второе — только после принятого acknowledgement. Если данные для первого сообщения ещё не записаны, интерфейс обязан показать это как отдельную ошибку, а не использовать сетевой значок вместо результата.'),
+ heading('Учебная fixture: ключ запроса стабилен, ключ попытки меняется'),
+ paragraph('В модели пользователь пишет черновик версии 1. Для него создаётся requestKey: draft-v1. Первая попытка получает attempt-01. Если модель не получает acknowledgement, она показывает result-unknown и сохраняет черновик как источник восстановления. После явно объявленного возвращения в online разрешён один retry: он сохраняет тот же request key, но использует attempt-02. Это защищает от параллельного повтора и позволяет отличить поздний ответ первой попытки от ответа второй.'),
+ codeBlock(fixtureExample),
+ paragraph('Пятнадцать assertions проверяют именно эту границу. Они подтверждают, что fixture не вызывает Fetch и не создаёт browser API; offline блокирует планирование новой попытки, но не удаляет persisted draft; retry не меняет logical request key; stale acknowledgement не коммитит состояние. Такой тест не доказывает доступность сети и не заменяет интеграционный сценарий. Он делает явными правила UI до того, как эти правила разъедутся между click handler, storage и toast.'),
+ figure(
+ '/assets/editorial/2022/poor-network-state-2022.svg',
+ 'Диаграмма учебных состояний отправки: черновик готов, offline блокирует попытку и оставляет копию; online создаёт ожидающую попытку; отсутствие model acknowledgement ведёт в результат неизвестен; подтверждение возвращает статус подтверждён, а новый черновик остаётся отдельно.',
+ 'Схема показывает пользовательское состояние, а не сетевую трассу. Стрелки «offline» и «unknown» — входы учебной модели, не измерение соединения.',
+ ),
+ heading('Маршрут: симптом → причина → проверка → действие'),
+ orderedList([
+ 'Симптом. После нажатия Save интерфейс быстро показывает успех, повторяет запрос при повторном клике либо открывается с пустым текстом после offline.',
+ 'Причина. Черновик, транспортная попытка и подтверждённая операция записываются в одно поле состояния. Отсутствие ответа трактуется как failure или success без отдельного правила.',
+ 'Проверка данных. Выпишите отдельно draft version, persisted draft, request key, attempt key, phase и acknowledgement payload. Если одно поле отвечает за два пункта, найдите владельца каждого перехода.',
+ 'Проверка неизвестного исхода. Смоделируйте ветку без acknowledgement. В ней не должно быть success и не должен исчезать persisted draft. В реальном проекте эту ветку затем проверяют отдельным контролируемым сценарием, а не выдают модель за trace.',
+ 'Действие. До acknowledgement показывайте «результат пока неизвестен», отключайте параллельный retry и разрешайте один осознанный повтор с тем же logical request key.',
+ 'Восстановление. Дайте безопасный путь оставить черновик для проверки. Автоматически очищайте только версию, которую признал payload; более новый текст не принадлежит старой попытке.',
+ ]),
+ heading('Где хранить черновик — отдельное решение'),
+ paragraph('Fixture называет persistence in-memory-copy-only. Это намеренное ограничение: переменная в учебной модели переживает только её выполнение, а не перезагрузку, вкладки или сбой процесса. Реальное приложение должно отдельно выбрать хранилище, срок жизни, шифрование, правила очистки и поведение при нескольких вкладках. Service Workers CRD от 12 июля 2022 года показывает, что сервисный worker — событийный контекст с собственным жизненным циклом; это не основание предполагать, что он всегда запущен или уже спас конкретный черновик.'),
+ paragraph('Полезно начать с малого контракта: «после каждого изменения черновик либо сохранён вместе с версией, либо UI честно показывает, что локальной копии нет». Потом добавляется реальное хранилище и отдельный тест его ошибок. Не стоит прятать это решение под названием offline cache. Для текста профиля и для перевода денег различаются срок хранения, чувствительность данных, право на повтор и даже допустимое сообщение на экране.'),
+ heading('Ограничение и следующий проверяемый шаг'),
+ paragraph('Эта модель не выбирает HTTP-метод, не реализует idempotency endpoint, не выполняет request, не хранит данные после рестарта и не решает конфликт двух устройств. RFC 9110 говорит о свойствах методов, но прикладной requestKey остаётся вашим контрактом: он должен быть понятен серверу и связан с предметной операцией, если она допускает повтор. Нельзя перенести строку draft-v1 из fixture в production как готовый механизм защиты.'),
+ paragraph('Следующий шаг — взять одну настоящую форму и написать рядом с ней таблицу из четырёх состояний. Затем добавить локальный тест: редактирование создаёт новую version; отсутствие acknowledgement сохраняет черновик; поздний ответ не перезаписывает новый текст. После этого команда может провести отдельный browser и network сценарий с названными версиями среды. Его результаты будут новым артефактом, а не обещанием этой статьи.'),
+ heading('Историческая граница июля 2022'),
+ paragraph('Здесь использованы неизменяемые источники, доступные к июлю 2022 года: Fetch Review Draft от 19 декабря 2021 года, Service Workers Candidate Recommendation Draft от 12 июля 2022 года и RFC 9110 июня 2022 года. Ни один из них не используется как доказательство конкретного offline-прогона. Все ключи, версии, acknowledgement и видимые тексты — данные учебной модели пакета.'),
+ ],
+ commonSources,
+);
+
+const mechanismArticle = createRevision(
+ {
+ slug: 'editorial-2022-07-mechanism-poor-network',
+ title: 'Плохая сеть: граница между попыткой, подтверждением и черновиком',
+ categories: ['Frontend', 'Архитектура'],
+ cover: '/assets/editorial/2022/poor-network-retry-contract-2022.svg',
+ excerpt: 'Разбираем контракт, в котором повтор не создаёт новый логический запрос, старый ответ не стирает новый черновик, а неизвестный исход остаётся видимым состоянием.',
+ readingMinutes: 14,
+ },
+ [
+ paragraph('Самая неприятная ошибка плохой сети выглядит аккуратно: кнопка перестала крутиться, форма закрылась, пользователь уверен, что всё сохранено. Через минуту приходит старый ответ, а в это время уже набран новый текст. Если обработчик не различает попытки, поздний payload очищает новый черновик или повторно запускает переход. Цена — потеря работы и состояние, которое невозможно объяснить по одному скриншоту.'),
+ paragraph('Причина обычно не в одном timeout. Экрану не принадлежит знание о том, была ли операция выполнена на другой стороне; ему принадлежит только локальная последовательность: создан черновик, запланирована попытка, принят или не принят acknowledgement. В этой статье механизм описан учебной моделью. В ней нет HTTP, реального server acknowledgement или таймера. Payload — объект JavaScript с пометкой teaching-model-payload-not-server-response, поэтому выводы относятся к contract, а не к сети.'),
+ heading('Два ключа отвечают на два разных вопроса'),
+ paragraph('Request key отвечает на вопрос «какую логическую операцию пытается завершить пользователь?». Attempt key отвечает на другой: «какой именно запуск этой операции сейчас ожидает ответ?». При unknown outcome повтор не должен молча создавать новую логическую операцию; ему нужен тот же request key и новый attempt key. Иначе серверная сторона не сможет сопоставить повтор с исходным намерением, а клиент — отличить поздний ответ первого запуска от актуального второго.'),
+ dataTable(
+ 'Разделение идентификаторов в учебном контракте',
+ ['Поле', 'Кто создаёт', 'Когда меняется', 'Что защищает'],
+ [
+ ['draft.version', 'редактор черновика', 'при каждом содержательном изменении', 'новый текст от очистки старой версии'],
+ ['requestKey', 'submit boundary', 'при новой версии, до первой попытки', 'смысл одной операции и серверную идемпотентность, если сервис её поддерживает'],
+ ['attemptKey', 'retry guard', 'при каждой разрешённой попытке', 'поздний или duplicate reply от неверного commit'],
+ ['submissionId', 'acknowledgement payload модели', 'после признанного подтверждения', 'видимую связь UI с подтверждённой операцией'],
+ ],
+ ),
+ heading('Почему retry guard — это не просто disabled button'),
+ paragraph('Кнопку можно временно заблокировать CSS-классом, но contract остаётся дырявым, если другой handler, повторный event или восстановление state могут создать ещё одну попытку. Guard должен находиться возле transition. В модели beginSubmission отказывает, если фаза уже awaiting-ack или unknown-outcome. retryUnknownOutcome проверяет именно unknown phase, объявленное online state и лимит одной повторной попытки. Это решение модели, не универсальная retry policy.'),
+ paragraph('Лимит в одну попытку здесь выбран, чтобы было видно место решения. Продукту может потребоваться другой лимит, ручной confirm или вовсе запрет повтора. Важно не число, а доказуемость: для каждого retry можно назвать request key, attempt key, владелец правила и сообщение пользователю. Цикл «повторяем пока не получится» особенно опасен там, где человек уже не наблюдает экран и где операция имеет внешнее последствие.'),
+ heading('Учебный код: unknown outcome не подменяется ошибкой'),
+ paragraph('После первой попытки fixture вызывает markUnknownOutcome. Она не получает exception и не измеряет канал связи. Функция лишь проверяет, что её attempt key совпадает с текущей, меняет фазу на unknown-outcome и сохраняет note о черновике. Следующий retry создаёт attempt-02, но оставляет draft-v1. Такой порядок можно выполнить как обычный JavaScript и проверить без browser sandbox.'),
+ codeBlock(contractExample),
+ paragraph('Это специально бедный пример. Он не учит, как вызвать fetch(), не имитирует задержку, не обрабатывает network exception и не утверждает, что сервис обработал request key. Его ценность в другом: reviewer видит, что UI не имеет права перейти в success только из того, что локальный обработчик закончил работу. Переход в success допускает только acknowledgement с совпадающими ключами и версией.'),
+ figure(
+ '/assets/editorial/2022/poor-network-retry-contract-2022.svg',
+ 'Схема contract: draft version создаёт request key; первая попытка получает attempt-01; при unknown outcome retry использует прежний request key и attempt-02; acknowledgement коммитится только если совпадают оба ключа и accepted version. Старый ответ отмечен как stale и не меняет черновик.',
+ 'Разделение request и attempt позволяет сделать поздний ответ наблюдаемым, но не опасным для текущего состояния.',
+ ),
+ heading('Ack — это сообщение, а не зелёная анимация'),
+ paragraph('Acknowledgement модели содержит submissionId, requestKey и acceptedVersion. Эти поля проверяются до commit. Если пришёл ответ от attempt-01, когда UI ждёт attempt-02, результат называется stale-or-invalid-acknowledgement и ничего не меняет. Если та же acknowledgement приходит второй раз после commit, она называется duplicate-acknowledgement. Это не ошибка транспорта и не HTTP-код: это способ не позволить одному и тому же UI transition случиться дважды.'),
+ paragraph('Особенно важна accepted version. Пользователь может продолжить редактирование, пока первоначальная операция ожидает подтверждения. Тогда draft становится версией 2, а acknowledgement относится к версии 1. Очистить текущий текст было бы потерей данных. Fixture оставляет version 2 и показывает acknowledged-newer-draft-retained. Значит, экран одновременно знает две вещи: прежняя операция признана, но новая работа ещё не вошла в неё.'),
+ heading('Связь с HTTP без ложного вывода'),
+ paragraph('RFC 9110 использует точное понятие идемпотентности для HTTP-методов, но frontend не должен на этом основании выбирать любые повторы. Метод, тело запроса, предметная операция и поведение сервиса образуют один контракт. Например, даже если transport-level повтор допустим, экрану всё равно нужен способ показать пользователю, к какой версии относится подтверждение. Поэтому requestKey в статье не назван заголовком, endpoint-параметром или готовой схемой API: это роль, которую API и клиент должны согласовать.'),
+ paragraph('Исторический Fetch Review Draft полезен здесь другой границей: он описывает платформенный алгоритм выборки, но не знает бизнес-смысл «сохранить черновик». Переход из transport-level события к domain acknowledgement проект должен сделать сам. Если этот переход не оформлен, любые названия вроде isSuccess становятся удобной, но ложной абстракцией.'),
+ heading('Маршрут: симптом → причина → проверка → действие'),
+ orderedList([
+ 'Симптом. Старая попытка отвечает после retry, дубль ответа повторно закрывает форму или новый текст исчезает после acknowledgement старой версии.',
+ 'Причина. UI хранит один «pending» флаг, не связывает reply с attempt key либо считает acknowledgement равным визуальному окончанию handler-а.',
+ 'Проверка ключей. В логике состояния найдите request key, attempt key и version. Сравните их в одном месте до каждого commit; не полагайтесь на порядок arrival.',
+ 'Проверка guard. Запустите fixture и убедитесь, что retry до unknown phase и retry в declared offline не создают попытку. Затем добавьте аналогичный unit test рядом с реальным reducer или store.',
+ 'Действие. Введите phase machine: idle, awaiting acknowledgement, unknown outcome, acknowledged, recovery required. Дайте каждому переходу owner и видимый текст.',
+ 'Откат. Если результат неизвестен, оставьте persisted draft. Не пытайтесь «откатить сервер» без отдельного доменного контракта; сначала дайте человеку безопасно сохранить собственный текст.',
+ ]),
+ heading('Граница service worker и фоновой работы'),
+ paragraph('Service Workers CRD в июле 2022 года описывает событийный worker и прямо требует учитывать, что user agent может остановить его. Это важная причина не строить UI-обещание на предположении «фоновая часть всё завершит». Но из этого не следует запрет использовать service worker в продукте. Следует более узкое правило: если конкретный механизм берёт на себя retry или persistence, его жизненный цикл, очередь, права и результат acknowledgement должны быть видны в собственном contract и тестах.'),
+ paragraph('Учебная модель не создаёт service worker специально. Иначе простой вопрос о владении draft был бы спрятан за API. Когда базовая state machine уже есть, команда может добавить следующий уровень: как реальное хранилище восстанавливает version, как worker передаёт факт доставки в открытый документ, что происходит при обновлении worker и когда пользователь видит «нужно проверить». Каждая из этих веток потребует нового проверяемого артефакта.'),
+ heading('Ограничение и следующий шаг'),
+ paragraph('Модель ограничена одним активным logical request и одним разрешённым retry. Она не решает несколько вкладок, auth refresh, очередь изменений, конфликт сервера, CRDT, encryption и настоящий idempotency storage. Она также не доказывает, что network state браузера соответствует строке online. Чем сложнее предметная операция, тем опаснее подменять эти открытые вопросы словом «offline sync».'),
+ paragraph('Следующий шаг — провести contract review одной формы вместе с API-владельцем. Зафиксируйте: что является logical operation, где живёт request key, какой payload подтверждает версию, как называется unknown outcome, кто ограничивает retry и когда черновик можно удалить. После этой таблицы добавьте две проверки: stale acknowledgement не меняет state, acknowledgement старой версии не очищает новый draft. Это уже практическая защита от наиболее дорогой ошибки — необъяснимой потери пользовательского текста.'),
+ heading('Историческая граница июля 2022'),
+ paragraph('Источники фиксированы датированными URL: Fetch Review Draft 2021, Service Workers CRD от 12 июля 2022 года и RFC 9110 июня 2022 года. Статья не переносит назад более поздние API и не заявляет, что service worker или HTTP-клиент этой fixture были запущены. Названия phases и ключей — проектные значения, пригодные для обсуждения и unit-проверки.'),
+ ],
+ commonSources,
+);
+
+const fieldArticle = createRevision(
+ {
+ slug: 'editorial-2022-07-field-poor-network',
+ title: 'Плохая сеть: диагностировать неизвестный результат и не потерять черновик',
+ categories: ['Frontend', 'Тестирование'],
+ cover: '/assets/editorial/2022/poor-network-diagnosis-recovery-2022.svg',
+ excerpt: 'Полевой маршрут для спорного случая: пользователь не получил ответ, старая попытка отвечает поздно, а интерфейс должен сохранить текст и предложить проверяемое восстановление.',
+ readingMinutes: 14,
+ },
+ [
+ paragraph('После жалобы «сохранение иногда пропадает» легко начать с увеличения timeout. Это опасная реакция: проблема может быть в раннем success toast, в очистке draft до acknowledgement, в повторной отправке или в позднем ответе, который относится к уже неактуальной попытке. Если сразу менять transport, команда не узнает, какой факт видит пользователь. Цена ошибки — потерянный текст и повторяющийся дефект, который нельзя надёжно воспроизвести из-за отсутствия состояния.'),
+ paragraph('Для диагностики я бы взял один учебный сценарий и запретил ему изображать реальную сеть. В fixture есть declared offline, затем unknown-outcome, затем один retry и payload acknowledgement. Нет fetch, network throttling, browser trace, таймеров и сервера. Эта узкая граница полезна: можно сначала доказать, что UI выдерживает stale reply и сохраняет новый draft, а уже потом собирать реальный стенд с версиями и условиями.'),
+ heading('Сначала назовите наблюдение, а не диагноз'),
+ dataTable(
+ 'Матрица первичной диагностики',
+ ['Наблюдение на экране', 'Нельзя заключать', 'Что проверить в state', 'Безопасное первое действие'],
+ [
+ ['Пользователь не увидел результата', 'операция точно не выполнена', 'phase, request key, attempt key, acknowledgement', 'показать unknown outcome и оставить draft'],
+ ['Успех показан сразу после клика', 'сервер подтвердил версию', 'кто выставляет success state и от какого payload', 'перенести success после validation acknowledgement'],
+ ['После retry приходит старый ответ', 'это подтверждение текущей попытки', 'совпадает ли attempt key', 'проигнорировать stale reply без очистки draft'],
+ ['Открылся пустой редактор', 'сохранение прошло', 'persisted draft и его version перед очисткой', 'восстановить локальную копию или честно показать её отсутствие'],
+ ['Пользователь набрал новый текст', 'старый ack можно применить ко всему экрану', 'accepted version и current draft version', 'подтвердить старую операцию, но сохранить новый текст'],
+ ],
+ ),
+ heading('Один разбор: поздний ответ после повторной попытки'),
+ paragraph('В модели пользователь создаёт draft версии 1, запускает attempt-01 и не получает model acknowledgement. Экран показывает результат неизвестен. Offline label блокирует retry, но draft остаётся в persisted copy. После declared online создаётся attempt-02 с тем же requestKey. Затем прилетает payload для первой попытки. Он выглядит правдоподобно: та же версия и тот же request key. Но attempt key другой, значит это не разрешение текущего ожидания. Модель возвращает stale-or-invalid-acknowledgement и не меняет visible state.'),
+ paragraph('Это правило защищает не от «плохого ответа», а от неправильного владельца перехода. Старый payload может быть полезен для журнала или отдельной сверки, но он не должен закрывать форму автоматически. Сначала UI обязан решить, что он подтверждает. В учебном контракте ответ подтверждает только конкретную ожидаемую попытку. Реальный продукт может выбрать более сложную сверку через серверный статус, но тогда и это действие нужно описать отдельно, а не спрятать в catch-block.'),
+ heading('Fixture: поздний и duplicate payload не делают второй commit'),
+ paragraph('Ниже показан только вызов принятия acknowledgement. До него fixture уже проверила ключи и фазу. Payload создан вручную; поле submissionId — не ID реальной базы. Сначала ответ от attempt-01 отвергается как stale. Затем acknowledgement для attempt-02 принимает версию 1. Если после этого пришёл ещё один такой же payload, результат — duplicate-acknowledgement, а второй commit не происходит.'),
+ codeBlock(recoveryExample),
+ paragraph('В сценарии есть ещё одна неприятная деталь. Между retry и правильным acknowledgement пользователь меняет текст. Draft становится версией 2. Accepted payload относится к версии 1, поэтому модель выбирает acknowledged-newer-draft-retained. Она не скрывает прежний успех, но не уничтожает новую работу. Такой ответ интерфейса длиннее одного toast, зато он объясняет ситуацию: «предыдущее сохранение подтверждено; текущая правка ещё черновик».'),
+ figure(
+ '/assets/editorial/2022/poor-network-diagnosis-recovery-2022.svg',
+ 'Диагностическая схема: отсутствие учебного acknowledgement оставляет draft и показывает unknown outcome; offline retry блокируется; online retry получает новый attempt key; reply старой попытки игнорируется; ack версии 1 не очищает новый draft версии 2; отдельная recovery-ветка сохраняет draft для ручной проверки.',
+ 'Схема не изображает HTTP-пакеты. Она помогает выбрать действие для каждого наблюдаемого состояния, прежде чем менять сетевой слой.',
+ ),
+ heading('Восстановление должно быть безопаснее догадки'),
+ paragraph('Когда outcome неизвестен, интерфейс часто предлагает слишком смелую кнопку: «Отправить ещё раз». Она может быть полезна, но не должна быть единственным путем. В модели keepDraftForRecovery переводит фазу в recovery-required, оставляет persisted draft и не планирует transport. Это не откат сервера и не восстановление связи. Это честный переход: пользовательский текст сохранён, а предметный результат требует отдельной проверки.'),
+ paragraph('Такой путь особенно важен для операций, где повтор имеет цену. Для комментария можно предусмотреть согласованный retry; для изменения реквизитов или финансового действия может потребоваться сверка со списком операций, обращение в поддержку или серверный status endpoint. Текст статьи не назначает политику за продукт. Он требует, чтобы UI не стирал единственный артефакт, который точно находится под его контролем: локально сохранённый черновик.'),
+ heading('Как собрать реальную проверку после модели'),
+ paragraph('После unit fixture нужна другая работа, и её нельзя выдать за уже сделанную. Зафиксируйте browser, версию приложения, способ включения offline или потери ответа, тип формы, содержимое тестового черновика и ожидаемые видимые states. Запишите, где лежит реальное persistence, как очищается test data и каким способом сервер выдаёт acknowledgement. Затем отдельно проведите сценарии: отправка без ответа, retry, stale reply, редактирование до позднего ответа и восстановление после перезагрузки.'),
+ paragraph('Результатом должен стать trace или тестовый отчёт с условиями, а не просто фраза «проверено на плохой сети». W3C Service Workers CRD напоминает, что worker — не вечный фон. Fetch Review Draft описывает слой platform fetching, но не бизнес-подтверждение. Эти источники полезны, чтобы не путать уровни, однако не заменяют наблюдение выбранного браузера и API конкретного продукта.'),
+ heading('Маршрут: симптом → причина → проверка → действие'),
+ orderedList([
+ 'Симптом. Зафиксируйте один факт: success без acknowledgement, пустой draft, старый reply после retry или новая версия, исчезнувшая после save. Не называйте это «сетью» до проверки state.',
+ 'Причина. Проверьте, не объединены ли draft, persisted copy, request key, attempt key и acknowledgement одним boolean или одним reducer case.',
+ 'Проверка модели. Выполните node web/scripts/upgrade-2022-07.mjs --verify-fixture. PASS означает только соблюдение учебного контракта: offline block, unknown outcome, retry guard, stale/duplicate reply и retention нового draft.',
+ 'Проверка реализации. В реальном коде найдите все места, которые очищают draft или показывают success. Для каждого запишите, каким acknowledgement и какой version он разрешён.',
+ 'Действие. Добавьте named visible states и один безопасный путь recovery. Запретите очистку persisted draft, пока accepted version не совпала с его версией.',
+ 'Проверка после изменения. Проведите контролируемый browser-сценарий с документированными условиями. Если он расходится с моделью, исправьте contract или зафиксируйте платформенное ограничение, а не маскируйте его повтором.',
+ ]),
+ heading('Что не исправляет эта статья'),
+ paragraph('Не исправит проблему один глобальный indicator online/offline. Он может быть полезным сигналом, но не сообщает, приняла ли предметная операция версию текста. Не исправит проблему и бесконечный retry: он может ухудшить последствия и скрыть факт неизвестного исхода. Не исправит её один service worker: без contract для keys, persistence и acknowledgement фоновые задачи делают состояние менее, а не более понятным.'),
+ paragraph('Модель также не покрывает несколько вкладок, параллельные формы, undo после подтверждения, безопасность локального текста, права пользователя, обновление приложения и различие между DNS, captive portal и серверной ошибкой. Эти темы нельзя свести к одному boolean network state. Хорошая новость в том, что базовое разделение draft / attempt / acknowledgement остаётся полезным и перед следующей сложностью: оно даёт место, куда добавить новый факт, не ломая существующие переходы.'),
+ heading('Ограничение и следующий шаг'),
+ paragraph('Учебная модель не является network test и не даёт production SLO. Её ключи, retry limit, тексты и payload придуманы для детерминированной проверки. Если ваш API не умеет признать logical request или не возвращает версию, нельзя заявлять, что простая client-side защита решила дубли. Нужно сначала договориться о server contract и только затем выбирать transport и UI.'),
+ paragraph('Следующий практический шаг — добавить в одну форму журнал переходов для development или теста: draft version, request key, attempt key, phase, source acknowledgement и причина очистки. Не храните в нём чувствительный текст. Затем попросите reviewer пройти матрицу этой статьи на одном PR. Если он может показать, почему старый payload не меняет новый draft и где остаётся текст после unknown outcome, у изменения уже есть проверяемая граница.'),
+ heading('Историческая граница июля 2022'),
+ paragraph('Пакет опирается на Fetch Review Draft 2021 года, Service Workers Candidate Recommendation Draft от 12 июля 2022 года и RFC 9110 июня 2022 года. Все источники имеют датированные, неизменяемые URL. Они используются для терминов и границ платформы; fixture не утверждает, что в ней был реальный offline, HTTP-запрос, service worker или server acknowledgement.'),
+ ],
+ commonSources,
+);
+
+export const revisions = [practiceArticle, mechanismArticle, fieldArticle].map(({ proseLength, ...revision }) => revision);
+
+function verifyFixture() {
+ const fixture = runPoorNetworkFixture();
+ const failed = Object.entries(fixture.assertions)
+ .filter(([, passed]) => passed !== true)
+ .map(([name]) => name);
+ if (failed.length > 0) {
+ console.error('FAIL fixture: ' + failed.join(', '));
+ process.exitCode = 1;
+ return;
+ }
+ console.log('PASS fixture: ' + Object.keys(fixture.assertions).length + '/' + Object.keys(fixture.assertions).length + ' assertions');
+}
+
+if (process.argv.includes('--verify-fixture')) verifyFixture();
+if (process.argv.includes('--print-revisions')) console.log(JSON.stringify(revisions));