revise July 2022 poor network articles
Build and deploy / deploy (push) Successful in 13s

This commit is contained in:
2026-07-31 13:51:28 +03:00
parent 2bfc9d929f
commit ec1fa93fd2
7 changed files with 673 additions and 1 deletions
+2
View File
@@ -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,
];
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="540" viewBox="0 0 900 540" role="img" aria-labelledby="title desc">
<title id="title">Диагностика unknown outcome и безопасное восстановление черновика</title>
<desc id="desc">Дерево решений показывает, как от отсутствия acknowledgement перейти к проверке ключей, игнорированию старого ответа, сохранению нового черновика или ручному восстановлению без ложного отката.</desc>
<defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0 0L10 5L0 10z" fill="#475569"/></marker><style>.box{stroke:#475569;stroke-width:3;rx:14}.h{font:700 22px Arial,sans-serif;fill:#0f172a}.t{font:17px Arial,sans-serif;fill:#334155}.s{font:15px Arial,sans-serif;fill:#64748b}.line{stroke:#475569;stroke-width:4;fill:none;marker-end:url(#arrow)}.q{font:700 16px Arial,sans-serif;fill:#7c3aed}</style></defs>
<rect width="900" height="540" fill="#fbfdff"/>
<text x="42" y="46" class="h">Диагностика: не угадывать результат, сохранить контролируемые данные</text>
<rect x="324" y="84" width="252" height="90" class="box" fill="#fee2e2"/><text x="352" y="121" class="h">Нет acknowledgement</text><text x="352" y="151" class="t">visible: result unknown</text>
<rect x="72" y="257" width="215" height="112" class="box" fill="#fef3c7"/><text x="94" y="296" class="h">Offline label?</text><text x="94" y="326" class="t">retry blocked</text><text x="94" y="350" class="s">draft retained</text>
<rect x="343" y="257" width="215" height="112" class="box" fill="#dbeafe"/><text x="365" y="296" class="h">Reply пришёл?</text><text x="365" y="326" class="t">keys совпали?</text><text x="365" y="350" class="s">attempt + request + v</text>
<rect x="631" y="257" width="215" height="112" class="box" fill="#e9d5ff"/><text x="653" y="296" class="h">Нужна сверка</text><text x="653" y="326" class="t">recovery required</text><text x="653" y="350" class="s">не transport rollback</text>
<rect x="343" y="420" width="215" height="76" class="box" fill="#dcfce7"/><text x="365" y="452" class="h">Commit ack</text><text x="365" y="478" class="t">newer draft stays</text>
<path d="M450 174V244H180V248" class="line"/><text x="228" y="231" class="q">нет retry</text>
<path d="M450 174V248" class="line"/><text x="462" y="220" class="q">есть reply</text>
<path d="M450 174V244H737V248" class="line"/><text x="620" y="231" class="q">оставить draft</text>
<path d="M450 369V411" class="line"/><text x="464" y="397" class="q">current ack</text>
<text x="45" y="523" class="s">Вопросы относятся к state machine. Реальная сеть, серверная сверка и persistence требуют отдельного теста с условиями.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="540" viewBox="0 0 900 540" role="img" aria-labelledby="title desc">
<title id="title">Контракт ключей повторной отправки</title>
<desc id="desc">Схема связывает версию черновика с request key, две попытки с разными attempt key и acknowledgement, которое проходит проверку всех полей. Старый ответ отмечен как stale.</desc>
<defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0 0L10 5L0 10z" fill="#334155"/></marker><style>.box{stroke:#334155;stroke-width:3;rx:14}.h{font:700 23px Arial,sans-serif;fill:#0f172a}.t{font:18px Arial,sans-serif;fill:#334155}.s{font:15px Arial,sans-serif;fill:#64748b}.line{stroke:#334155;stroke-width:4;fill:none;marker-end:url(#arrow)}.ok{fill:#166534;font:700 17px Arial,sans-serif}.bad{fill:#b91c1c;font:700 17px Arial,sans-serif}</style></defs>
<rect width="900" height="540" fill="#f8fafc"/>
<text x="42" y="47" class="h">Контракт retry: один logical request, разные попытки</text>
<rect x="46" y="106" width="195" height="120" class="box" fill="#e0f2fe"/><text x="67" y="145" class="h">draft v1</text><text x="67" y="177" class="t">requestKey</text><text x="67" y="203" class="t">draft-v1</text>
<rect x="347" y="84" width="205" height="142" class="box" fill="#fef3c7"/><text x="368" y="123" class="h">attempt-01</text><text x="368" y="155" class="t">ожидает ack</text><text x="368" y="185" class="s">unknown outcome</text>
<rect x="347" y="316" width="205" height="142" class="box" fill="#dbeafe"/><text x="368" y="355" class="h">attempt-02</text><text x="368" y="387" class="t">тот же draft-v1</text><text x="368" y="417" class="s">один guard retry</text>
<rect x="658" y="84" width="196" height="142" class="box" fill="#fee2e2"/><text x="679" y="123" class="h">старый ack</text><text x="679" y="155" class="t">attempt-01</text><text x="679" y="188" class="bad">STALE: ignore</text>
<rect x="658" y="316" width="196" height="142" class="box" fill="#dcfce7"/><text x="679" y="355" class="h">accepted ack</text><text x="679" y="387" class="t">attempt-02 + v1</text><text x="679" y="420" class="ok">commit once</text>
<path d="M241 166H338" class="line"/><path d="M451 226V307" class="line"/><path d="M552 155H648" class="line"/><path d="M552 387H648" class="line"/>
<text x="50" y="497" class="s">Commit требует совпадения request key, current attempt key и accepted version. Это учебная схема, не ответ сервера.</text>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="540" viewBox="0 0 900 540" role="img" aria-labelledby="title desc">
<title id="title">Учебные состояния отправки черновика при плохой сети</title>
<desc id="desc">Диаграмма показывает черновик, блокировку offline, ожидание acknowledgement, неизвестный результат, повтор и подтверждение. Все состояния помечены как учебные.</desc>
<defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0 0L10 5L0 10z" fill="#1d4ed8"/></marker><style>.box{stroke-width:3;rx:16}.h{font:700 25px Arial,sans-serif;fill:#102a43}.t{font:18px Arial,sans-serif;fill:#243b53}.s{font:15px Arial,sans-serif;fill:#52667a}.line{stroke:#1d4ed8;stroke-width:4;fill:none;marker-end:url(#arrow)}.label{font:700 16px Arial,sans-serif;fill:#1d4ed8}</style></defs>
<rect width="900" height="540" fill="#f7fbff"/>
<text x="42" y="48" class="h">Учебная модель: видимое состояние важнее догадки о сети</text>
<rect x="45" y="100" width="210" height="112" class="box" fill="#e0f2fe" stroke="#0284c7"/><text x="68" y="140" class="h">Черновик готов</text><text x="68" y="172" class="t">version + local copy</text><text x="68" y="196" class="s">не равно server save</text>
<rect x="45" y="330" width="210" height="112" class="box" fill="#fef3c7" stroke="#d97706"/><text x="68" y="370" class="h">Offline</text><text x="68" y="402" class="t">попытка не планируется</text><text x="68" y="426" class="s">черновик остаётся</text>
<rect x="345" y="100" width="210" height="112" class="box" fill="#dbeafe" stroke="#2563eb"/><text x="368" y="140" class="h">Ждём ack</text><text x="368" y="172" class="t">request + attempt key</text><text x="368" y="196" class="s">success ещё запрещён</text>
<rect x="345" y="330" width="210" height="112" class="box" fill="#fee2e2" stroke="#dc2626"/><text x="368" y="370" class="h">Unknown</text><text x="368" y="402" class="t">нет model acknowledgement</text><text x="368" y="426" class="s">не факт о сервере</text>
<rect x="645" y="100" width="210" height="112" class="box" fill="#dcfce7" stroke="#16a34a"/><text x="668" y="140" class="h">Подтверждено</text><text x="668" y="172" class="t">ack + accepted version</text><text x="668" y="196" class="s">новый draft отдельно</text>
<path d="M255 156H337" class="line"/><text x="273" y="140" class="label">submit</text>
<path d="M150 212V321" class="line"/><text x="170" y="274" class="label">declared offline</text>
<path d="M345 183C295 235 294 274 345 331" class="line"/><text x="240" y="260" class="label">нет ack</text>
<path d="M555 156H637" class="line"/><text x="576" y="140" class="label">ack</text>
<path d="M555 386C624 386 625 216 556 184" class="line"/><text x="578" y="310" class="label">retry: тот же request,</text><text x="612" y="332" class="label">новый attempt</text>
<text x="45" y="495" class="s">Граница: SVG описывает contract UI; здесь нет HTTP, Fetch, service worker, trace или измеренного network outcome.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+552
View File
@@ -0,0 +1,552 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.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));