From ec1fa93fd25b12ba3519651085b6e71e8705d40d Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 13:51:28 +0300 Subject: [PATCH] revise July 2022 poor network articles --- editorial/production/README.md | 2 +- editorial/reviews/2022-07-draft.md | 69 +++ web/data/editorial-revisions.mjs | 2 + .../poor-network-diagnosis-recovery-2022.svg | 17 + .../2022/poor-network-retry-contract-2022.svg | 14 + .../2022/poor-network-state-2022.svg | 18 + web/scripts/upgrade-2022-07.mjs | 552 ++++++++++++++++++ 7 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 editorial/reviews/2022-07-draft.md create mode 100644 web/public/assets/editorial/2022/poor-network-diagnosis-recovery-2022.svg create mode 100644 web/public/assets/editorial/2022/poor-network-retry-contract-2022.svg create mode 100644 web/public/assets/editorial/2022/poor-network-state-2022.svg create mode 100644 web/scripts/upgrade-2022-07.mjs 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 @@ + + Диагностика unknown outcome и безопасное восстановление черновика + Дерево решений показывает, как от отсутствия acknowledgement перейти к проверке ключей, игнорированию старого ответа, сохранению нового черновика или ручному восстановлению без ложного отката. + + + Диагностика: не угадывать результат, сохранить контролируемые данные + Нет acknowledgementvisible: result unknown + Offline label?retry blockeddraft retained + Reply пришёл?keys совпали?attempt + request + v + Нужна сверкаrecovery requiredне transport rollback + Commit acknewer draft stays + нет retry + есть reply + оставить draft + current ack + Вопросы относятся к state machine. Реальная сеть, серверная сверка и persistence требуют отдельного теста с условиями. + 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 @@ + + Контракт ключей повторной отправки + Схема связывает версию черновика с request key, две попытки с разными attempt key и acknowledgement, которое проходит проверку всех полей. Старый ответ отмечен как stale. + + + Контракт retry: один logical request, разные попытки + draft v1requestKeydraft-v1 + attempt-01ожидает ackunknown outcome + attempt-02тот же draft-v1один guard retry + старый ackattempt-01STALE: ignore + accepted ackattempt-02 + v1commit once + + Commit требует совпадения request key, current attempt key и accepted version. Это учебная схема, не ответ сервера. + 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 @@ + + Учебные состояния отправки черновика при плохой сети + Диаграмма показывает черновик, блокировку offline, ожидание acknowledgement, неизвестный результат, повтор и подтверждение. Все состояния помечены как учебные. + + + Учебная модель: видимое состояние важнее догадки о сети + Черновик готовversion + local copyне равно server save + Offlineпопытка не планируетсячерновик остаётся + Ждём ackrequest + attempt keysuccess ещё запрещён + Unknownнет model acknowledgementне факт о сервере + Подтвержденоack + accepted versionновый draft отдельно + submit + declared offline + нет ack + ack + retry: тот же request,новый attempt + Граница: SVG описывает contract UI; здесь нет HTTP, Fetch, service worker, trace или измеренного network outcome. + 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 '

' + text + '

'; +} + +function codeBlock(lines) { + return '
' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function dataTable(caption, headers, rows) { + const head = '' + headers.map((header) => '' + header + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; + return '
' + head + body + '
' + caption + '
'; +} + +function sourceList(items) { + return ''; +} + +function plainText(content) { + return content + .replace(/<[^>]+>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function bodyText(content) { + return plainText(content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); +} + +function createRevision(meta, bodyParts, sources) { + if (sources.length < 2) throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника'); + const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) { + throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength); + } + return { ...meta, contentHtml, proseLength }; +} + +const fetchReviewDraft = { + title: 'WHATWG Fetch Standard, Review Draft от 19 декабря 2021 года', + url: 'https://fetch.spec.whatwg.org/review-drafts/2021-12/', + note: 'датированный первичный снимок Fetch Standard. Он задаёт модель request, response и network error; он не превращает отсутствие application acknowledgement в доказательство, что серверное действие не произошло.', +}; + +const serviceWorkersDraft = { + title: 'W3C Service Workers, Candidate Recommendation Draft от 12 июля 2022 года', + url: 'https://www.w3.org/TR/2022/CRD-service-workers-20220712/', + note: 'неизменяемая версия периода: service worker событийный и может быть остановлен user agent. В этой партии service worker намеренно не создаётся; документ используется только как историческая граница платформы.', +}; + +const httpSemantics = { + title: 'RFC 9110: HTTP Semantics, июнь 2022 года', + url: 'https://www.rfc-editor.org/rfc/rfc9110.html', + note: 'нормативный RFC, доступный к июлю 2022 года. Он различает свойства HTTP-методов и не заменяет прикладной ключ запроса или подтверждение предметной операции.', +}; + +const commonSources = [fetchReviewDraft, serviceWorkersDraft, httpSemantics]; + +/** + * Детерминированная учебная in-memory модель отправки черновика при плохой сети. + * В ней нет Fetch, HTTP, service worker, DOM, localStorage, IndexedDB, браузера, + * таймеров, throttling trace, benchmark, внешнего процесса или реального сервера. + * networkState, requestKey, attemptKey и acknowledgement — проектные данные модели. + * `unknown-outcome` означает только отсутствие model acknowledgement, а не факт о сети. + */ +function createTeachingOfflineSubmit() { + return { + boundary: Object.freeze({ + kind: 'teaching-offline-submit-contract-v1', + fetch: 'not-called', + http: 'not-sent', + serviceWorker: 'not-created', + dom: 'not-created', + browser: 'not-run', + persistence: 'in-memory-copy-only', + transportOutcome: 'not-observed', + }), + state: { + networkState: 'online', + draft: { version: 0, text: '' }, + persistedDraft: { version: 0, text: '' }, + submission: { + phase: 'idle', + requestKey: null, + attemptKey: null, + submittedVersion: null, + retryCount: 0, + acknowledgement: null, + }, + nextAttemptNumber: 1, + visibleState: 'draft-ready', + recoveryNote: null, + }, + }; +} + +function copyDraft(draft) { + return { version: draft.version, text: draft.text }; +} + +function writeDraft(model, text) { + const next = { version: model.state.draft.version + 1, text: String(text) }; + model.state.draft = next; + model.state.persistedDraft = copyDraft(next); + if (model.state.submission.phase === 'awaiting-ack') { + model.state.visibleState = 'editing-newer-draft-awaiting-ack'; + } else if (model.state.submission.phase === 'unknown-outcome') { + model.state.visibleState = 'editing-newer-draft-result-unknown'; + } else { + model.state.visibleState = next.text.trim() ? 'draft-ready' : 'draft-empty'; + } + return Object.freeze({ kind: 'draft-persisted', draft: copyDraft(next), persistence: 'teaching-in-memory-copy' }); +} + +function setNetworkState(model, networkState) { + if (!['online', 'offline'].includes(networkState)) return Object.freeze({ kind: 'invalid-network-label', changed: false }); + const before = model.state.networkState; + model.state.networkState = networkState; + return Object.freeze({ kind: 'network-state-declared', before, after: networkState, observation: 'teaching-label-not-network-measurement' }); +} + +function nextAttemptKey(model) { + const key = 'attempt-' + String(model.state.nextAttemptNumber).padStart(2, '0'); + model.state.nextAttemptNumber += 1; + return key; +} + +function beginSubmission(model) { + const submission = model.state.submission; + if (model.state.networkState !== 'online') { + model.state.visibleState = 'saved-locally-offline'; + return Object.freeze({ kind: 'blocked-offline', changed: false, draft: copyDraft(model.state.persistedDraft) }); + } + if (!model.state.persistedDraft.text.trim()) return Object.freeze({ kind: 'blocked-empty-draft', changed: false }); + if (submission.phase === 'awaiting-ack' || submission.phase === 'unknown-outcome') { + return Object.freeze({ kind: 'retry-guard-active', changed: false, phase: submission.phase }); + } + const requestKey = 'draft-v' + model.state.persistedDraft.version; + const attemptKey = nextAttemptKey(model); + model.state.submission = { + phase: 'awaiting-ack', + requestKey, + attemptKey, + submittedVersion: model.state.persistedDraft.version, + retryCount: 0, + acknowledgement: null, + }; + model.state.visibleState = 'sending-awaiting-ack'; + return Object.freeze({ kind: 'submission-planned', requestKey, attemptKey, submittedVersion: model.state.persistedDraft.version, transport: 'not-performed' }); +} + +function markUnknownOutcome(model, attemptKey) { + const submission = model.state.submission; + if (submission.phase !== 'awaiting-ack' || submission.attemptKey !== attemptKey) { + return Object.freeze({ kind: 'stale-or-unexpected-loss', changed: false }); + } + submission.phase = 'unknown-outcome'; + model.state.visibleState = 'result-unknown'; + model.state.recoveryNote = 'No teaching acknowledgement for ' + attemptKey + '; retained draft is the recovery source.'; + return Object.freeze({ kind: 'unknown-outcome', requestKey: submission.requestKey, attemptKey, meaning: 'no-model-acknowledgement-not-a-network-observation' }); +} + +function retryUnknownOutcome(model) { + const submission = model.state.submission; + if (submission.phase !== 'unknown-outcome') return Object.freeze({ kind: 'retry-not-available', changed: false, phase: submission.phase }); + if (model.state.networkState !== 'online') return Object.freeze({ kind: 'retry-blocked-offline', changed: false, retainedDraft: copyDraft(model.state.persistedDraft) }); + if (submission.retryCount >= 1) return Object.freeze({ kind: 'retry-guard-exhausted', changed: false, requestKey: submission.requestKey }); + const attemptKey = nextAttemptKey(model); + submission.phase = 'awaiting-ack'; + submission.attemptKey = attemptKey; + submission.retryCount += 1; + model.state.visibleState = 'retrying-awaiting-ack'; + return Object.freeze({ kind: 'retry-planned', requestKey: submission.requestKey, attemptKey, retryCount: submission.retryCount, transport: 'not-performed' }); +} + +function acceptTeachingAcknowledgement(model, payload) { + const submission = model.state.submission; + if (submission.phase === 'acknowledged' && submission.attemptKey === payload?.attemptKey) { + return Object.freeze({ kind: 'duplicate-acknowledgement', changed: false, acknowledgement: submission.acknowledgement }); + } + if ( + submission.phase !== 'awaiting-ack' + || submission.attemptKey !== payload?.attemptKey + || submission.requestKey !== payload?.requestKey + || submission.submittedVersion !== payload?.acceptedVersion + || typeof payload?.submissionId !== 'string' + ) { + return Object.freeze({ kind: 'stale-or-invalid-acknowledgement', changed: false }); + } + const acknowledgement = Object.freeze({ + submissionId: payload.submissionId, + requestKey: payload.requestKey, + acceptedVersion: payload.acceptedVersion, + source: 'teaching-model-payload-not-server-response', + }); + submission.phase = 'acknowledged'; + submission.acknowledgement = acknowledgement; + model.state.recoveryNote = null; + if (model.state.persistedDraft.version === payload.acceptedVersion) { + model.state.draft = { version: payload.acceptedVersion, text: '' }; + model.state.persistedDraft = { version: payload.acceptedVersion, text: '' }; + model.state.visibleState = 'acknowledged-draft-cleared'; + } else { + model.state.visibleState = 'acknowledged-newer-draft-retained'; + } + return Object.freeze({ kind: 'acknowledgement-accepted', changed: true, acknowledgement, visibleState: model.state.visibleState }); +} + +function keepDraftForRecovery(model) { + const submission = model.state.submission; + if (submission.phase !== 'unknown-outcome') return Object.freeze({ kind: 'recovery-not-available', changed: false }); + submission.phase = 'recovery-required'; + model.state.visibleState = 'draft-retained-needs-review'; + model.state.recoveryNote = 'Draft remains persisted; no teaching retry is planned.'; + return Object.freeze({ kind: 'recovery-preserved-draft', changed: true, retainedDraft: copyDraft(model.state.persistedDraft), requestKey: submission.requestKey }); +} + +function runPoorNetworkFixture() { + const model = createTeachingOfflineSubmit(); + const firstDraft = writeDraft(model, 'Отправить правку без потери черновика.'); + const offline = setNetworkState(model, 'offline'); + const blocked = beginSubmission(model); + const persistedAfterOfflineBlock = copyDraft(model.state.persistedDraft); + const online = setNetworkState(model, 'online'); + const firstAttempt = beginSubmission(model); + const unknown = markUnknownOutcome(model, firstAttempt.attemptKey); + const offlineAgain = setNetworkState(model, 'offline'); + const blockedRetry = retryUnknownOutcome(model); + setNetworkState(model, 'online'); + const retry = retryUnknownOutcome(model); + const submissionBeforeStaleAck = Object.freeze({ + phase: model.state.submission.phase, + requestKey: model.state.submission.requestKey, + attemptKey: model.state.submission.attemptKey, + acknowledgement: model.state.submission.acknowledgement, + }); + const staleAck = acceptTeachingAcknowledgement(model, { + attemptKey: firstAttempt.attemptKey, + requestKey: firstAttempt.requestKey, + acceptedVersion: firstAttempt.submittedVersion, + submissionId: 'submission-old', + }); + const submissionAfterStaleAck = Object.freeze({ + phase: model.state.submission.phase, + requestKey: model.state.submission.requestKey, + attemptKey: model.state.submission.attemptKey, + acknowledgement: model.state.submission.acknowledgement, + }); + const newerDraft = writeDraft(model, 'Новая правка, сделанная до model acknowledgement.'); + const accepted = acceptTeachingAcknowledgement(model, { + attemptKey: retry.attemptKey, + requestKey: retry.requestKey, + acceptedVersion: firstAttempt.submittedVersion, + submissionId: 'submission-42', + }); + const persistedAfterAcknowledgement = copyDraft(model.state.persistedDraft); + const visibleAfterAcknowledgement = model.state.visibleState; + const duplicate = acceptTeachingAcknowledgement(model, { + attemptKey: retry.attemptKey, + requestKey: retry.requestKey, + acceptedVersion: firstAttempt.submittedVersion, + submissionId: 'submission-42', + }); + + const recoveryModel = createTeachingOfflineSubmit(); + writeDraft(recoveryModel, 'Черновик для ветки recovery.'); + const recoveryAttempt = beginSubmission(recoveryModel); + markUnknownOutcome(recoveryModel, recoveryAttempt.attemptKey); + const recovery = keepDraftForRecovery(recoveryModel); + + const assertions = Object.freeze({ + modelDoesNotCallFetch: model.boundary.fetch === 'not-called' && model.boundary.http === 'not-sent', + modelDoesNotCreateServiceWorkerOrDom: model.boundary.serviceWorker === 'not-created' && model.boundary.dom === 'not-created', + draftIsCopiedIntoTeachingPersistence: firstDraft.draft.version === 1 && blocked.draft.text.includes('без потери'), + offlineIsOnlyADeclaredModelState: offline.observation === 'teaching-label-not-network-measurement' && online.after === 'online', + offlineBlocksInitialSubmissionWithoutDroppingDraft: blocked.kind === 'blocked-offline' && persistedAfterOfflineBlock.version === firstDraft.draft.version && persistedAfterOfflineBlock.text === firstDraft.draft.text, + firstAttemptHasStableRequestAndDistinctAttemptKey: firstAttempt.requestKey === 'draft-v1' && firstAttempt.attemptKey === 'attempt-01', + missingModelAckCreatesUnknownOutcome: unknown.kind === 'unknown-outcome' && unknown.meaning.startsWith('no-model-acknowledgement'), + retryIsBlockedWhileOffline: offlineAgain.after === 'offline' && blockedRetry.kind === 'retry-blocked-offline', + retryKeepsRequestKeyAndChangesAttemptKey: retry.kind === 'retry-planned' && retry.requestKey === firstAttempt.requestKey && retry.attemptKey === 'attempt-02', + staleReplyDoesNotChangeCurrentSubmission: staleAck.kind === 'stale-or-invalid-acknowledgement' && submissionBeforeStaleAck.phase === 'awaiting-ack' && submissionAfterStaleAck.phase === submissionBeforeStaleAck.phase && submissionAfterStaleAck.requestKey === submissionBeforeStaleAck.requestKey && submissionAfterStaleAck.attemptKey === retry.attemptKey && submissionAfterStaleAck.acknowledgement === null, + newerDraftIsPreservedWhenOldVersionIsAcknowledged: newerDraft.draft.version === 2 && accepted.kind === 'acknowledgement-accepted' && accepted.visibleState === 'acknowledged-newer-draft-retained' && visibleAfterAcknowledgement === 'acknowledged-newer-draft-retained' && persistedAfterAcknowledgement.version === 2 && persistedAfterAcknowledgement.text === newerDraft.draft.text, + acknowledgementIsATeachingPayload: accepted.acknowledgement.source === 'teaching-model-payload-not-server-response', + duplicateAcknowledgementDoesNotCreateSecondCommit: duplicate.kind === 'duplicate-acknowledgement' && model.state.submission.acknowledgement.submissionId === 'submission-42', + recoveryKeepsDraftInsteadOfPretendingRollbackSucceeded: recovery.kind === 'recovery-preserved-draft' && recoveryModel.state.persistedDraft.text.includes('recovery'), + recoveryDoesNotSendTransport: recoveryModel.boundary.transportOutcome === 'not-observed' && recoveryModel.state.submission.phase === 'recovery-required', + }); + return Object.freeze({ model, firstDraft, blocked, firstAttempt, unknown, blockedRetry, retry, staleAck, newerDraft, accepted, duplicate, recoveryModel, recovery, assertions }); +} + +const fixtureExample = [ + '# Запускается только in-memory учебная модель.', + 'node web/scripts/upgrade-2022-07.mjs --verify-fixture', + '', + '# PASS fixture: 15/15 assertions', + '# Нет Fetch, HTTP, service worker, DOM, localStorage, таймера или trace.', +].join('\n'); + +const contractExample = [ + 'const model = createTeachingOfflineSubmit();', + 'writeDraft(model, "Правка профиля");', + 'const first = beginSubmission(model);', + 'markUnknownOutcome(model, first.attemptKey);', + '', + '// visibleState: "result-unknown"', + '// Это отсутствие teaching acknowledgement, а не утверждение о сервере.', + 'const retry = retryUnknownOutcome(model);', + '// requestKey тот же; attemptKey новый; transport не выполняется.', +].join('\n'); + +const recoveryExample = [ + 'const payload = {', + ' attemptKey: "attempt-02",', + ' requestKey: "draft-v1",', + ' acceptedVersion: 1,', + ' submissionId: "submission-42",', + '};', + '', + 'acceptTeachingAcknowledgement(model, payload);', + '// stale reply с attempt-01 будет проигнорирован.', + '// Если появился draft v2, acknowledgement v1 не очищает v2.', +].join('\n'); + +const practiceArticle = createRevision( + { + slug: 'editorial-2022-07-practice-poor-network', + title: 'Плохая сеть: не показывать успех, пока действие не подтверждено', + categories: ['Frontend', 'Надёжность'], + cover: '/assets/editorial/2022/poor-network-state-2022.svg', + excerpt: 'Практический контракт для формы, которая должна сохранить черновик, честно показать неизвестный результат и не создать второй смысловой запрос при повторе.', + readingMinutes: 13, + }, + [ + paragraph('На плохой сети кнопка «Сохранить» легко врёт. Пользователь нажал её, экран уже показал зелёное сообщение, но приложение не получило подтверждения. При следующем открытии формы черновик исчезает; при повторном нажатии команда отправляет вторую операцию. Цена ошибки не в сером индикаторе: человек не знает, можно ли закрыть страницу, нужно ли повторять действие и какая версия текста теперь считается сохранённой.'), + paragraph('В июле 2022 года я бы начал не с offline-first лозунга и не с эмулятора сети. Сначала нужен видимый контракт одного действия: черновик отдельно от подтверждённого результата, явное состояние сети как вход интерфейса, ключ логического запроса и ключ конкретной попытки. Ниже — учебная in-memory fixture. Она не вызывает Fetch, не запускает service worker и не получает ответ сервера. Поэтому её состояния 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));