Files
progcode/web/scripts/upgrade-2022-07.mjs
huncode ec1fa93fd2
Build and deploy / deploy (push) Successful in 13s
revise July 2022 poor network articles
2026-07-31 13:51:28 +03:00

553 lines
68 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
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 и не получает ответ сервера. Поэтому её состояния <code>offline</code> и <code>unknown-outcome</code> — проектные labels, а не отчёт о реальной сети.'),
heading('Четыре состояния, которые нельзя склеивать'),
paragraph('У отправки есть как минимум четыре разных факта. Первый: текст черновика сохранён локально в выбранном хранилище. Второй: интерфейс сейчас считает сеть недоступной или доступной. Третий: конкретная попытка ожидает прикладное подтверждение. Четвёртый: серверное подтверждение с идентификатором предметной операции действительно принято клиентской логикой. Если заменить эти факты одним boolean <code>isSaved</code>, UI обязательно назовёт один из них другим.'),
dataTable(
'Видимый контракт отправки черновика',
['Состояние интерфейса', 'Что известно', 'Что нельзя утверждать', 'Действие'],
[
['Черновик готов', 'есть сохранённая локальная копия и её версия', 'что сервер видел текст', 'дать редактировать и показать дату/версию локальной копии'],
['Ожидаем подтверждение', 'создан request key и одна attempt key', 'что операция уже завершилась', 'не показывать success; защитить повторный клик'],
['Результат неизвестен', 'для попытки нет прикладного acknowledgement', 'что операция не дошла до сервера', 'сохранить черновик, дать повторить или перейти к восстановлению'],
['Подтверждено', 'получен payload с request key, версией и submission id', 'что более новый черновик можно удалить', 'очистить только подтверждённую версию, новый draft оставить'],
],
),
heading('Почему «запрос упал» не равно «ничего не произошло»'),
paragraph('Fetch Standard описывает request, response и network error, но у прикладного экрана другая задача: он должен решить, что показать человеку без домысла о чужой стороне. Между началом операции и сообщением UI могли существовать промежуточные границы, которых экран не видит. Поэтому здесь не используется фраза «сервер точно не сохранил». Вместо неё есть более узкое и проверяемое условие: текущая модель не получила acknowledgement, связанный с текущими <code>requestKey</code> и <code>attemptKey</code>.'),
paragraph('Это продолжает июньский разбор ошибок формы. Там важно было не перезаписать исправленное поле поздней ошибкой. Здесь важно не стереть текст и не объявить победу поздним или отсутствующим подтверждением. В обоих случаях источник проблемы — два разных состояния получают одно имя «сохранено». Версия черновика, попытка доставки и признанный результат должны жить рядом, но не заменять друг друга.'),
paragraph('Видимый текст должен следовать этой модели. «Черновик сохранён на устройстве» и «изменение подтверждено» — разные сообщения с разными условиями показа. Первое можно показать после успешного шага persistence выбранного приложения, второе — только после принятого acknowledgement. Если данные для первого сообщения ещё не записаны, интерфейс обязан показать это как отдельную ошибку, а не использовать сетевой значок вместо результата.'),
heading('Учебная fixture: ключ запроса стабилен, ключ попытки меняется'),
paragraph('В модели пользователь пишет черновик версии 1. Для него создаётся <code>requestKey: draft-v1</code>. Первая попытка получает <code>attempt-01</code>. Если модель не получает acknowledgement, она показывает <code>result-unknown</code> и сохраняет черновик как источник восстановления. После явно объявленного возвращения в <code>online</code> разрешён один retry: он сохраняет тот же request key, но использует <code>attempt-02</code>. Это защищает от параллельного повтора и позволяет отличить поздний ответ первой попытки от ответа второй.'),
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([
'<strong>Симптом.</strong> После нажатия Save интерфейс быстро показывает успех, повторяет запрос при повторном клике либо открывается с пустым текстом после offline.',
'<strong>Причина.</strong> Черновик, транспортная попытка и подтверждённая операция записываются в одно поле состояния. Отсутствие ответа трактуется как failure или success без отдельного правила.',
'<strong>Проверка данных.</strong> Выпишите отдельно draft version, persisted draft, request key, attempt key, phase и acknowledgement payload. Если одно поле отвечает за два пункта, найдите владельца каждого перехода.',
'<strong>Проверка неизвестного исхода.</strong> Смоделируйте ветку без acknowledgement. В ней не должно быть success и не должен исчезать persisted draft. В реальном проекте эту ветку затем проверяют отдельным контролируемым сценарием, а не выдают модель за trace.',
'<strong>Действие.</strong> До acknowledgement показывайте «результат пока неизвестен», отключайте параллельный retry и разрешайте один осознанный повтор с тем же logical request key.',
'<strong>Восстановление.</strong> Дайте безопасный путь оставить черновик для проверки. Автоматически очищайте только версию, которую признал payload; более новый текст не принадлежит старой попытке.',
]),
heading('Где хранить черновик — отдельное решение'),
paragraph('Fixture называет persistence <code>in-memory-copy-only</code>. Это намеренное ограничение: переменная в учебной модели переживает только её выполнение, а не перезагрузку, вкладки или сбой процесса. Реальное приложение должно отдельно выбрать хранилище, срок жизни, шифрование, правила очистки и поведение при нескольких вкладках. Service Workers CRD от 12 июля 2022 года показывает, что сервисный worker — событийный контекст с собственным жизненным циклом; это не основание предполагать, что он всегда запущен или уже спас конкретный черновик.'),
paragraph('Полезно начать с малого контракта: «после каждого изменения черновик либо сохранён вместе с версией, либо UI честно показывает, что локальной копии нет». Потом добавляется реальное хранилище и отдельный тест его ошибок. Не стоит прятать это решение под названием offline cache. Для текста профиля и для перевода денег различаются срок хранения, чувствительность данных, право на повтор и даже допустимое сообщение на экране.'),
heading('Ограничение и следующий проверяемый шаг'),
paragraph('Эта модель не выбирает HTTP-метод, не реализует idempotency endpoint, не выполняет request, не хранит данные после рестарта и не решает конфликт двух устройств. RFC 9110 говорит о свойствах методов, но прикладной <code>requestKey</code> остаётся вашим контрактом: он должен быть понятен серверу и связан с предметной операцией, если она допускает повтор. Нельзя перенести строку <code>draft-v1</code> из 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 с пометкой <code>teaching-model-payload-not-server-response</code>, поэтому выводы относятся к 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. В модели <code>beginSubmission</code> отказывает, если фаза уже <code>awaiting-ack</code> или <code>unknown-outcome</code>. <code>retryUnknownOutcome</code> проверяет именно unknown phase, объявленное online state и лимит одной повторной попытки. Это решение модели, не универсальная retry policy.'),
paragraph('Лимит в одну попытку здесь выбран, чтобы было видно место решения. Продукту может потребоваться другой лимит, ручной confirm или вовсе запрет повтора. Важно не число, а доказуемость: для каждого retry можно назвать request key, attempt key, владелец правила и сообщение пользователю. Цикл «повторяем пока не получится» особенно опасен там, где человек уже не наблюдает экран и где операция имеет внешнее последствие.'),
heading('Учебный код: unknown outcome не подменяется ошибкой'),
paragraph('После первой попытки fixture вызывает <code>markUnknownOutcome</code>. Она не получает exception и не измеряет канал связи. Функция лишь проверяет, что её attempt key совпадает с текущей, меняет фазу на <code>unknown-outcome</code> и сохраняет note о черновике. Следующий retry создаёт <code>attempt-02</code>, но оставляет <code>draft-v1</code>. Такой порядок можно выполнить как обычный JavaScript и проверить без browser sandbox.'),
codeBlock(contractExample),
paragraph('Это специально бедный пример. Он не учит, как вызвать <code>fetch()</code>, не имитирует задержку, не обрабатывает 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 модели содержит <code>submissionId</code>, <code>requestKey</code> и <code>acceptedVersion</code>. Эти поля проверяются до commit. Если пришёл ответ от <code>attempt-01</code>, когда UI ждёт <code>attempt-02</code>, результат называется <code>stale-or-invalid-acknowledgement</code> и ничего не меняет. Если та же acknowledgement приходит второй раз после commit, она называется <code>duplicate-acknowledgement</code>. Это не ошибка транспорта и не HTTP-код: это способ не позволить одному и тому же UI transition случиться дважды.'),
paragraph('Особенно важна accepted version. Пользователь может продолжить редактирование, пока первоначальная операция ожидает подтверждения. Тогда draft становится версией 2, а acknowledgement относится к версии 1. Очистить текущий текст было бы потерей данных. Fixture оставляет version 2 и показывает <code>acknowledged-newer-draft-retained</code>. Значит, экран одновременно знает две вещи: прежняя операция признана, но новая работа ещё не вошла в неё.'),
heading('Связь с HTTP без ложного вывода'),
paragraph('RFC 9110 использует точное понятие идемпотентности для HTTP-методов, но frontend не должен на этом основании выбирать любые повторы. Метод, тело запроса, предметная операция и поведение сервиса образуют один контракт. Например, даже если transport-level повтор допустим, экрану всё равно нужен способ показать пользователю, к какой версии относится подтверждение. Поэтому <code>requestKey</code> в статье не назван заголовком, endpoint-параметром или готовой схемой API: это роль, которую API и клиент должны согласовать.'),
paragraph('Исторический Fetch Review Draft полезен здесь другой границей: он описывает платформенный алгоритм выборки, но не знает бизнес-смысл «сохранить черновик». Переход из transport-level события к domain acknowledgement проект должен сделать сам. Если этот переход не оформлен, любые названия вроде <code>isSuccess</code> становятся удобной, но ложной абстракцией.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'<strong>Симптом.</strong> Старая попытка отвечает после retry, дубль ответа повторно закрывает форму или новый текст исчезает после acknowledgement старой версии.',
'<strong>Причина.</strong> UI хранит один «pending» флаг, не связывает reply с attempt key либо считает acknowledgement равным визуальному окончанию handler-а.',
'<strong>Проверка ключей.</strong> В логике состояния найдите request key, attempt key и version. Сравните их в одном месте до каждого commit; не полагайтесь на порядок arrival.',
'<strong>Проверка guard.</strong> Запустите fixture и убедитесь, что retry до unknown phase и retry в declared offline не создают попытку. Затем добавьте аналогичный unit test рядом с реальным reducer или store.',
'<strong>Действие.</strong> Введите phase machine: idle, awaiting acknowledgement, unknown outcome, acknowledged, recovery required. Дайте каждому переходу owner и видимый текст.',
'<strong>Откат.</strong> Если результат неизвестен, оставьте 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 браузера соответствует строке <code>online</code>. Чем сложнее предметная операция, тем опаснее подменять эти открытые вопросы словом «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 <code>offline</code>, затем <code>unknown-outcome</code>, затем один 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, запускает <code>attempt-01</code> и не получает model acknowledgement. Экран показывает результат неизвестен. Offline label блокирует retry, но draft остаётся в persisted copy. После declared online создаётся <code>attempt-02</code> с тем же <code>requestKey</code>. Затем прилетает payload для первой попытки. Он выглядит правдоподобно: та же версия и тот же request key. Но attempt key другой, значит это не разрешение текущего ожидания. Модель возвращает <code>stale-or-invalid-acknowledgement</code> и не меняет visible state.'),
paragraph('Это правило защищает не от «плохого ответа», а от неправильного владельца перехода. Старый payload может быть полезен для журнала или отдельной сверки, но он не должен закрывать форму автоматически. Сначала UI обязан решить, что он подтверждает. В учебном контракте ответ подтверждает только конкретную ожидаемую попытку. Реальный продукт может выбрать более сложную сверку через серверный статус, но тогда и это действие нужно описать отдельно, а не спрятать в catch-block.'),
heading('Fixture: поздний и duplicate payload не делают второй commit'),
paragraph('Ниже показан только вызов принятия acknowledgement. До него fixture уже проверила ключи и фазу. Payload создан вручную; поле <code>submissionId</code> — не ID реальной базы. Сначала ответ от <code>attempt-01</code> отвергается как stale. Затем acknowledgement для <code>attempt-02</code> принимает версию 1. Если после этого пришёл ещё один такой же payload, результат — <code>duplicate-acknowledgement</code>, а второй commit не происходит.'),
codeBlock(recoveryExample),
paragraph('В сценарии есть ещё одна неприятная деталь. Между retry и правильным acknowledgement пользователь меняет текст. Draft становится версией 2. Accepted payload относится к версии 1, поэтому модель выбирает <code>acknowledged-newer-draft-retained</code>. Она не скрывает прежний успех, но не уничтожает новую работу. Такой ответ интерфейса длиннее одного 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 неизвестен, интерфейс часто предлагает слишком смелую кнопку: «Отправить ещё раз». Она может быть полезна, но не должна быть единственным путем. В модели <code>keepDraftForRecovery</code> переводит фазу в <code>recovery-required</code>, оставляет 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([
'<strong>Симптом.</strong> Зафиксируйте один факт: success без acknowledgement, пустой draft, старый reply после retry или новая версия, исчезнувшая после save. Не называйте это «сетью» до проверки state.',
'<strong>Причина.</strong> Проверьте, не объединены ли draft, persisted copy, request key, attempt key и acknowledgement одним boolean или одним reducer case.',
'<strong>Проверка модели.</strong> Выполните <code>node web/scripts/upgrade-2022-07.mjs --verify-fixture</code>. PASS означает только соблюдение учебного контракта: offline block, unknown outcome, retry guard, stale/duplicate reply и retention нового draft.',
'<strong>Проверка реализации.</strong> В реальном коде найдите все места, которые очищают draft или показывают success. Для каждого запишите, каким acknowledgement и какой version он разрешён.',
'<strong>Действие.</strong> Добавьте named visible states и один безопасный путь recovery. Запретите очистку persisted draft, пока accepted version не совпала с его версией.',
'<strong>Проверка после изменения.</strong> Проведите контролируемый 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));