rewrite 2026-09 and 2027 articles for reader-facing quality
Build and deploy / deploy (push) Successful in 18s
Build and deploy / deploy (push) Successful in 18s
This commit is contained in:
+247
-188
@@ -1,240 +1,299 @@
|
||||
function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); }
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
const p = (text) => '<p>' + text + '</p>';
|
||||
const h2 = (text) => '<h2>' + text + '</h2>';
|
||||
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
|
||||
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
|
||||
function plainText(content) { return content.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); }
|
||||
function bodyText(content) { return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, '')); }
|
||||
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
||||
function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; }
|
||||
|
||||
const REFERENCES = deepFreeze({
|
||||
openapi: { title: 'OpenAPI Specification 3.1.1', url: 'https://spec.openapis.org/oas/v3.1.1.html', version: 'версия 3.1.1, 24 октября 2024, версионированная спецификация' },
|
||||
http: { title: 'RFC 9110: HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110.html', version: 'RFC 9110, июнь 2022, неизменяемый текст RFC' },
|
||||
schema: { title: 'JSON Schema Core: draft 2020-12', url: 'https://json-schema.org/draft/2020-12/json-schema-core.html', version: 'draft 2020-12, Internet-Draft (work in progress), 16 июня 2022' },
|
||||
});
|
||||
function sourceList(entries) { return '<ul>' + entries.map(({ key, use, boundary }) => { const source = REFERENCES[key]; return '<li><a href="' + source.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(source.title) + '</a> — версия: ' + escapeHtml(source.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>'; }).join('') + '</ul>'; }
|
||||
function plainText(content) {
|
||||
return content.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
const FIXED_BOUNDARY_CASES = deepFreeze({
|
||||
'named-screen-boundary-v1': {
|
||||
id: 'named-screen-boundary-v1', family: 'fixed-frontend-backend-boundary-v1',
|
||||
ui: { localState: ['draft-filter', 'open-panel'], context: ['fixed-locale', 'fixed-view-permission'], forbiddenDomainClaims: [] },
|
||||
api: { command: { name: 'fixed-submit-choice', intent: 'set-choice', idempotency: 'named-key-v1' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status', 'allowedActions', 'messageCode'] }, error: { kind: 'named-problem', code: 'none', retry: 'not-requested' } },
|
||||
boundary: 'Named fixed synthetic JavaScript literal in memory. It describes no UI, user, HTTP request, network trace, file, secret, service, deployment or production state.',
|
||||
function bodyText(content) {
|
||||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||||
}
|
||||
|
||||
const REFERENCES = Object.freeze({
|
||||
http: {
|
||||
title: 'RFC 9110: HTTP Semantics',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc9110.html',
|
||||
version: 'RFC 9110, июнь 2022',
|
||||
},
|
||||
'ui-claims-domain-state-v1': {
|
||||
id: 'ui-claims-domain-state-v1', family: 'fixed-frontend-backend-boundary-v1',
|
||||
ui: { localState: ['draft-filter'], context: ['fixed-locale'], forbiddenDomainClaims: ['choice-confirmed'] },
|
||||
api: { command: { name: 'fixed-submit-choice', intent: 'set-choice', idempotency: 'named-key-v1' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status', 'allowedActions', 'messageCode'] }, error: { kind: 'named-problem', code: 'none', retry: 'not-requested' } }, boundary: 'Negative fixed synthetic literal only.',
|
||||
problem: {
|
||||
title: 'RFC 9457: Problem Details for HTTP APIs',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc9457.html',
|
||||
version: 'RFC 9457, июль 2023',
|
||||
},
|
||||
'unnamed-command-v1': {
|
||||
id: 'unnamed-command-v1', family: 'fixed-frontend-backend-boundary-v1',
|
||||
ui: { localState: ['draft-filter'], context: ['fixed-locale'], forbiddenDomainClaims: [] },
|
||||
api: { command: { name: '', intent: '', idempotency: '' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status'] }, error: { kind: 'named-problem', code: 'none', retry: 'not-requested' } }, boundary: 'Negative fixed synthetic literal only.',
|
||||
openapi: {
|
||||
title: 'OpenAPI Specification 3.1.1',
|
||||
url: 'https://spec.openapis.org/oas/v3.1.1.html',
|
||||
version: 'версия 3.1.1, 24 октября 2024',
|
||||
},
|
||||
'unactionable-error-v1': {
|
||||
id: 'unactionable-error-v1', family: 'fixed-frontend-backend-boundary-v1',
|
||||
ui: { localState: ['draft-filter'], context: ['fixed-locale'], forbiddenDomainClaims: [] },
|
||||
api: { command: { name: 'fixed-submit-choice', intent: 'set-choice', idempotency: 'named-key-v1' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status', 'allowedActions', 'messageCode'] }, error: { kind: 'thrown-value', code: '', retry: '' } }, boundary: 'Negative fixed synthetic literal only.',
|
||||
fetch: {
|
||||
title: 'WHATWG Fetch Standard',
|
||||
url: 'https://fetch.spec.whatwg.org/',
|
||||
version: 'живой стандарт WHATWG, разделы Fetch и response handling',
|
||||
},
|
||||
});
|
||||
|
||||
export function createFixedBoundaryCase(id = 'named-screen-boundary-v1') { const value = FIXED_BOUNDARY_CASES[id]; return value ? deepFreeze(clone(value)) : undefined; }
|
||||
function stop(status, reason, nextAction) { return deepFreeze({ accepted: false, status, reasons: deepFreeze([reason]), nextAction, handoff: 'not-eligible-for-synthetic-boundary-review', productionEffect: 'not-attempted' }); }
|
||||
export function reviewFixedStateOwnership(input) {
|
||||
if (!Object.values(FIXED_BOUNDARY_CASES).some((known) => JSON.stringify(known) === JSON.stringify(input))) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-case');
|
||||
if (!input.ui?.localState?.length || !input.ui?.context?.length) return stop('stop-ui-boundary-incomplete', 'local-state-or-context-is-not-named', 'name-local-state-and-context-separately');
|
||||
if (input.ui.forbiddenDomainClaims?.length) return stop('stop-ui-claims-domain-state', 'ui-literal-claims-domain-state', 'remove-domain-claim-and-request-a-read-model');
|
||||
const command = input.api?.command;
|
||||
if (!command?.name || !command.intent || !command.idempotency) return stop('stop-command-not-named', 'command-intent-or-idempotency-key-is-missing', 'name-command-intent-and-idempotency-boundary');
|
||||
const readModel = input.api?.readModel;
|
||||
if (!readModel?.name || !['status', 'allowedActions', 'messageCode'].every((field) => readModel.fields?.includes(field))) return stop('stop-read-model-incomplete', 'screen-read-model-is-not-decidable', 'name-status-actions-and-message-code');
|
||||
const error = input.api?.error;
|
||||
if (error?.kind !== 'named-problem' || !Object.hasOwn(error, 'code') || !Object.hasOwn(error, 'retry')) return stop('stop-error-not-actionable', 'error-is-not-a-named-problem-envelope', 'name-problem-code-and-retry-boundary');
|
||||
return deepFreeze({ accepted: true, status: 'synthetic-boundary-review-hand-off', caseId: input.id, uiOwns: deepFreeze([...input.ui.localState, ...input.ui.context]), apiOwns: deepFreeze([command.intent, readModel.name, 'named-problem-envelope']), boundary: input.boundary, productionEffect: 'not-attempted' });
|
||||
function sourceList(entries) {
|
||||
return '<ul>' + entries.map(({ key, use, boundary }) => {
|
||||
const source = REFERENCES[key];
|
||||
return '<li><a href="' + source.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(source.title) + '</a> — ' + escapeHtml(source.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>';
|
||||
}).join('') + '</ul>';
|
||||
}
|
||||
export function explainFixedBoundaryInference(input) {
|
||||
const review = reviewFixedStateOwnership(input);
|
||||
if (!review.accepted) return review;
|
||||
return deepFreeze({ status: 'synthetic-inference-boundary-explained', allowedConclusion: 'named-fields-support-only-a-synthetic-review-hand-off', forbiddenConclusion: 'no-real-ui-api-or-domain-state-is-observed', nextAction: 'hand-off-fixed-synthetic-boundary-review', productionEffect: 'not-attempted' });
|
||||
|
||||
const SCREEN_CONTRACT = Object.freeze({
|
||||
status: ['ready', 'pending', 'blocked'],
|
||||
allowedActions: ['retry', 'edit', 'cancel'],
|
||||
messageCode: 'string',
|
||||
});
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
export function runFixedBoundaryFixture() {
|
||||
const cases = [
|
||||
['named-screen-boundary-v1', 'synthetic-boundary-review-hand-off'], ['ui-claims-domain-state-v1', 'stop-ui-claims-domain-state'], ['unnamed-command-v1', 'stop-command-not-named'], ['unactionable-error-v1', 'stop-error-not-actionable'],
|
||||
];
|
||||
const checks = cases.map(([id, status]) => ({ id, expected: status, actual: reviewFixedStateOwnership(createFixedBoundaryCase(id)).status }));
|
||||
const inference = explainFixedBoundaryInference(createFixedBoundaryCase('named-screen-boundary-v1'));
|
||||
checks.push({ id: 'inference', expected: 'synthetic-inference-boundary-explained', actual: inference.status });
|
||||
const accepted = checks.every((check) => check.expected === check.actual) && inference.productionEffect === 'not-attempted';
|
||||
return deepFreeze({ passed: checks.filter((check) => check.expected === check.actual).length, total: checks.length, accepted, checks: deepFreeze(checks) });
|
||||
|
||||
function validateScreenResponse(value) {
|
||||
const errors = [];
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) errors.push('response-must-be-an-object');
|
||||
if (!SCREEN_CONTRACT.status.includes(value?.status)) errors.push('status-must-be-known');
|
||||
if (!Array.isArray(value?.allowedActions) || value.allowedActions.some((action) => !['retry', 'edit', 'cancel'].includes(action))) errors.push('allowed-actions-must-be-an-array-of-known-actions');
|
||||
if (typeof value?.messageCode !== 'string' || value.messageCode.length === 0) errors.push('message-code-must-be-a-non-empty-string');
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export function checkScreenResponse(value) {
|
||||
const result = validateScreenResponse(clone(value));
|
||||
return Object.freeze({ ...result, source: 'response-boundary-validator' });
|
||||
}
|
||||
|
||||
export function classifyHttpResponse(response) {
|
||||
const status = Number(response?.status);
|
||||
const contentType = String(response?.contentType || '').toLowerCase();
|
||||
const body = response?.body;
|
||||
if (!Number.isInteger(status) || status < 100 || status > 599) return { kind: 'invalid-observation', next: 'record-status-before-interpreting-body' };
|
||||
if (status === 204) return { kind: 'success-without-representation', next: 'do-not-parse-json' };
|
||||
if (status >= 200 && status < 300) {
|
||||
if (!contentType.includes('application/json')) return { kind: 'success-with-wrong-media-type', next: 'stop-before-reading-screen-model' };
|
||||
const contract = checkScreenResponse(body);
|
||||
return contract.valid ? { kind: 'screen-model-ready', next: 'render-from-contract' } : { kind: 'success-with-invalid-contract', next: 'show-transport-success-separately-and-stop-rendering', errors: contract.errors };
|
||||
}
|
||||
if (status === 409 || status === 422) return { kind: 'domain-rejection', next: 'read-problem-type-and-show-recoverable-action' };
|
||||
if (status === 429 || status >= 500) return { kind: 'retryable-or-temporary-failure', next: 'apply-explicit-retry-policy' };
|
||||
if (status >= 400) return { kind: 'client-or-authorisation-failure', next: 'show-problem-without-retrying-blindly' };
|
||||
return { kind: 'informational-or-unhandled', next: 'handle-explicitly' };
|
||||
}
|
||||
|
||||
export function diagnoseBoundaryObservation(input) {
|
||||
const response = input?.response;
|
||||
const ui = input?.ui;
|
||||
if (!response || typeof response !== 'object') return { status: 'missing-response', next: 'capture-status-and-content-type' };
|
||||
if (!ui || typeof ui !== 'object') return { status: 'missing-ui-observation', next: 'record-render-decision-and-request-id' };
|
||||
const classification = classifyHttpResponse(response);
|
||||
if (classification.kind === 'screen-model-ready' && ui.renderedStatus !== response.body.status) return { status: 'ui-api-state-mismatch', next: 'compare-render-input-with-response-body' };
|
||||
return { status: classification.kind, next: classification.next };
|
||||
}
|
||||
|
||||
const commonMeta = { readingMinutes: 10, tags: ['frontend', 'backend', 'contracts', 'architecture'] };
|
||||
const practice = {
|
||||
...commonMeta, slug: 'editorial-2026-09-practice-frontend-backend-boundary',
|
||||
title: 'План на сентябрь: карта границы между экраном и доменом',
|
||||
excerpt: 'Как до начала работ отделить локальное состояние UI от контекста и screen read model, не приписывая клиенту доменный факт.',
|
||||
readingMinutes: 12,
|
||||
tags: ['frontend', 'backend', 'http', 'contracts'],
|
||||
slug: 'editorial-2026-09-practice-frontend-backend-boundary',
|
||||
title: 'Ответ 200 — ещё не модель экрана: валидируем JSON на границе UI и API',
|
||||
excerpt: 'Почему успешный HTTP-статус не даёт права рендерить payload и как вынести проверку screen model в маленький воспроизводимый контракт.',
|
||||
contentHtml: [
|
||||
p('План на сентябрь 2026, составленный по источникам на 31 июля 2026: команда часто называет «состоянием экрана» всё, что лежит в store. В результате UI присваивает себе доменный факт — например, «выбор подтверждён» — до того, как этот факт вообще определён контрактом. Цена ошибки конкретна: спор о кнопке превращается в спор о правде системы, а повторный вход или иной клиент получает несовместимую версию того же решения.'),
|
||||
p('Задача этого сценария не в том, чтобы заменить API красивой схемой. Нужно до реализации разложить один экран на владельцев: что живёт только в UI, что является контекстом запроса и что backend обязан вернуть как read model. Если не назвать владельца, локальная оптимизация станет скрытым протоколом. Её стоимость проявится при ошибке, повторной команде или добавлении второго интерфейса: договорённость придётся восстанавливать по веткам компонентов.'),
|
||||
h2('Три слоя, которые нельзя называть одним словом'),
|
||||
p('Локальное состояние отвечает на вопрос «как пользователь сейчас управляет представлением»: открыт ли panel, что набрано в фильтре, какая вкладка выбрана до отправки. Оно может исчезнуть при закрытии view без изменения домена. Контекст не является результатом операции: locale, явно выданное permission или feature boundary задают, как интерпретировать запрос. Их нельзя подменять полем ответа и нельзя вычислять из CSS или текста кнопки.'),
|
||||
p('Доменное состояние отвечает на другой вопрос: что система считает действительным для предмета. У экрана нет оснований владеть им только потому, что он его показывает. В сентябрьском плане API возвращает не «всю сущность», а named screen read model: <code>status</code>, <code>allowedActions</code> и <code>messageCode</code>. Это достаточная форма для решения UI, но не лицензия выдумать новые переходы или причины отказа.'),
|
||||
figure('/assets/editorial/2026/frontend-backend-boundary-2026-state-ownership-map.svg', 'Схема из трёх колонок: UI владеет draft и открытой панелью, context содержит locale и permission, API возвращает status, allowed actions и message code. Стрелка команды идёт к API, а не к доменному факту внутри UI.', 'Карта владельцев не описывает настоящую систему. Это шаблон вопросов для сентябрьского design review.'),
|
||||
table('Карта ответственности для одного synthetic screen scenario', ['Объект', 'Владелец', 'Допустимый вопрос', 'Недопустимая подмена'], [
|
||||
['draft-filter', 'UI', 'что введено до команды?', 'назвать draft подтверждённым выбором'],
|
||||
['fixed-locale', 'context', 'в каком формате показать код?', 'вывести locale из результата операции'],
|
||||
['status', 'API read model', 'что разрешено показать?', 'превратить status в локальный флаг успеха'],
|
||||
['fixed-submit-choice', 'API command', 'какое намерение отправлено?', 'описать команду как прямую мутацию store'],
|
||||
['named-problem', 'contract', 'как UI может объяснить stop?', 'ловить произвольную thrown value'],
|
||||
p('Экран может показать «готово», получить ответ 200 и всё равно сломаться на следующем клике. Причина обычно не в HTTP: сервер вернул успешный статус, но поле переименовали, массив действий стал строкой или <code>messageCode</code> исчез из одного ответвления. Цена ошибки — рассинхрон между UI и API: пользователь видит устаревшее состояние, а команда ищет проблему в сети, хотя нарушение уже произошло на границе данных.'),
|
||||
p('Граница должна отвечать на два разных вопроса. Первый: завершился ли обмен по HTTP? Второй: можно ли безопасно использовать представление для конкретного экрана? Нельзя сводить их к одному boolean <code>ok</code>. В этой статье разберём минимальную screen model, проверим её на чистой функции и разложим действия при статусах 2xx. Подход одинаково применим в браузерном клиенте, BFF и интеграционном тесте.'),
|
||||
h2('HTTP-успех и успех контракта'),
|
||||
p('RFC 9110 описывает значение статус-кода как часть HTTP-сообщения. Код 200 сообщает, что запрос обработан успешно на уровне протокола и приложения, но конкретное содержимое ответа всё равно определяется контрактом ресурса. Клиент не получает права угадать форму по имени endpoint или по тому, что предыдущая версия возвращала похожий JSON.'),
|
||||
p('Поэтому полезно держать в коде последовательность: сначала принять статус, затем проверить media type, потом разобрать JSON, после этого валидировать поля и только в конце передать результат в view-model. Порядок кажется длиннее прямого <code>await response.json()</code>, но он локализует ошибку. Если payload не соответствует договорённости, компонент не обязан угадывать fallback и не превращает частично прочитанные данные в доменный факт.'),
|
||||
figure('/assets/editorial/2026/frontend-backend-boundary-2026-state-ownership-map.svg', 'Поток из пяти шагов: HTTP status, Content-Type, JSON parse, проверка screen model и рендер. Красная ветка останавливает обработку при ошибке контракта.', 'Валидатор стоит между транспортом и компонентом. Он не решает, как выглядит интерфейс; он не даёт невалидному payload пройти дальше.'),
|
||||
table('Минимальная screen model для одного состояния заказа', ['Поле', 'Тип', 'Зачем UI', 'Что делать при ошибке'], [
|
||||
['status', 'ready | pending | blocked', 'выбрать состояние экрана', 'не выбирать состояние по умолчанию молча'],
|
||||
['allowedActions', 'массив известных команд', 'показать доступные действия', 'скрыть команды и записать нарушение'],
|
||||
['messageCode', 'непустая строка', 'выбрать локализованное сообщение', 'показать безопасный общий текст'],
|
||||
['Content-Type', 'application/json', 'понять формат представления', 'не вызывать JSON parser вслепую'],
|
||||
['HTTP status', 'целое 100–599', 'разделить transport и application result', 'зафиксировать код до чтения тела'],
|
||||
]),
|
||||
h2('Карта начинается не с полей, а с права утверждать'),
|
||||
p('У каждого поля полезно спросить: кто имеет право изменить его смысл и кто должен увидеть изменение после повторного чтения? Если ответ «только текущий компонент», это кандидат в local state. Если ответ зависит от входной среды, это context с явным источником. Если ответ должен быть одинаково интерпретирован разными клиентами, это доменный факт или его проекция, и экран лишь получает его через контракт.'),
|
||||
p('Такое правило не требует тотального backend-for-frontend. Иногда read model собирается отдельным adapter, иногда один ресурс уже годится для нескольких view. Важно не место кода, а обязанность: читатель ответа понимает, какие action разрешены, без копирования rules в UI. Если для этого приходится держать в компоненте таблицу переходов, граница не названа; перенос HTTP-вызова сам по себе её не исправит.'),
|
||||
h2('Как распознать присвоение доменного состояния'),
|
||||
p('Первый маркер — optimistic flag с именем в прошедшем времени: <code>isConfirmed</code>, <code>wasPaid</code>, <code>hasAccess</code>. Сам по себе optimistic interaction не запрещён, но его следует назвать именно interaction: «команда отправлена», «локальная форма заблокирована», «ожидается новый read model». Когда такой флаг влияет на список разрешённых действий, он уже конкурирует с domain rule. План не требует убрать весь optimistic UI; он требует не выдавать временную реакцию за знание о предмете.'),
|
||||
p('Второй маркер — context, который появляется только после разбора ответа. Locale и permission обычно задают способ показать или запросить информацию; status описывает то, что вернул contract. Если перемешать их, UI начинает зависеть от исторического порядка: сначала пришёл ответ, потом вычислился context, потом поменялся текст. В сентябрьской карте контекст именуется отдельно именно чтобы reviewer мог спросить, откуда он взят, не разбирая payload и не строя гипотезу о session.'),
|
||||
p('Третий маркер — boolean, который скрывает причину. <code>canSubmit</code> полезен как derived view state, если он получен из <code>allowedActions</code> или локальной валидности формы. Он опасен как самостоятельный доменный флаг, потому что другой client уже не узнает, почему действие недоступно. Вместо одной универсальной причины model держит status и message code. Это не усложнение ради полноты: следующий экран получает явный вопрос, а не обязанность повторить условие.'),
|
||||
p('Наконец, карта не требует, чтобы API знал каждую анимацию или фокус. Такие детали остаются у UI и не обязаны переживать новый read. Плохая граница получается не тогда, когда клиент что-то хранит, а когда он хранит факт, который должен быть согласован между потребителями. Этот критерий помогает не уйти в архитектурную религию: локальное остаётся локальным, общее имеет контракт, а context не маскируется под оба случая.'),
|
||||
h2('Literal-проверка карты, а не имитация trace'),
|
||||
code("import { createFixedBoundaryCase, reviewFixedStateOwnership } from './upgrade-2026-09.mjs';\n\nconst input = createFixedBoundaryCase('ui-claims-domain-state-v1');\nconst result = reviewFixedStateOwnership(input);\nconsole.log({ status: result.status, reason: result.reasons[0], effect: result.productionEffect });\n// { status: 'stop-ui-claims-domain-state', reason: 'ui-literal-claims-domain-state', effect: 'not-attempted' }"),
|
||||
p('Этот snippet выполняется над named fixed synthetic literal, клонирует его через JSON и freeze-ит результат. Он не открывает browser, не читает сеть и не изображает настоящий экран. Полезный output здесь — stop с конкретной причиной. Положительная ветка функции даёт лишь <code>synthetic-boundary-review-hand-off</code>; она не означает approval, release или готовность к реализации.'),
|
||||
p('Для автора M9 эта проверка важна ещё и как способ писать короче. Вместо абзаца о том, «кто за что отвечает», он может показать поле, владельца и stop reason. Но краткость не должна скрывать цену переноса: если <code>status</code> становится локальным, UI берёт на себя обязанность поддерживать его после другой команды, другой вкладки и другого клиента. В плановой карте эта обязанность не делегируется компоненту. Она остаётся открытым вопросом к контракту, пока отдельный read model не даст основание для вывода. Нельзя закрыть этот вопрос именем store или выбором фреймворка: оба решения определяют хранение, но не источник domain truth.'),
|
||||
h2('Почему screen model не равна DTO «как есть»'),
|
||||
p('DTO транспорта может быть удобной исходной формой, но его наличие не отвечает на вопрос view. Screen model должна иметь ясное назначение: что показать, что разрешить, как обозначить проблемное состояние. OAS 3.1.1 полезна именно как описание интерфейса, независимое от языка; она не предписывает слой или форму state management. Поэтому в плане мы используем спецификацию, чтобы закрепить поля контракта, а не чтобы объявить каждый JSON API готовым для UI.'),
|
||||
p('В error path особенно легко смешать роли. HTTP status описывает результат обмена на уровне протокола, но UI всё ещё нуждается в именованной проблеме с <code>code</code> и <code>retry</code>. Нельзя вывести retry из «любого 5xx» без правила конкретного контракта. RFC 9110 задаёт семантику HTTP, а наша модель добавляет ограниченный project-level вопрос: может ли view показать следующий шаг, не угадывая смысл исключения.'),
|
||||
h2('Сентябрьская последовательность'),
|
||||
h2('Контракт должен быть маленьким, но решаемым'),
|
||||
p('Полная DTO предметной области редко нужна экрану. Если компонент читает двадцать полей, это не означает, что screen model должна копировать все двадцать. Выберите минимальный набор, который позволяет принять решение: состояние, разрешённые команды и ключ сообщения. Любое поле должно иметь владельца и правило изменения. Это снижает связность: backend может менять внутреннее представление, не заставляя UI разбирать чужой aggregate.'),
|
||||
p('При этом «минимальный» не значит «неформальный». Для каждого значения задайте допустимый словарь или тип. <code>status: "ok"</code> удобен только до появления второго смысла слова ok. Перечень <code>ready | pending | blocked</code> делает расширение видимым: новый статус потребует решения о рендеринге, тесте и обратной совместимости. Массив действий также должен быть закрытым или версионируемым; неизвестная команда не должна появляться как активная кнопка.'),
|
||||
h2('Проверка до рендера'),
|
||||
code("import { checkScreenResponse } from './upgrade-2026-09.mjs';\n\nconst response = {\n status: 'ready',\n allowedActions: ['edit'],\n messageCode: 'order.ready',\n};\n\nconsole.log(checkScreenResponse(response));\n// { valid: true, errors: [], source: 'response-boundary-validator' }\n\nconsole.log(checkScreenResponse({ status: 'done', allowedActions: 'edit' }));\n// { valid: false, errors: ['status-must-be-known', ...], source: 'response-boundary-validator' }"),
|
||||
p('Функция принимает копию JSON-совместимого значения и возвращает причины, а не исключение с произвольным текстом. Это не замена JSON Schema или типам на этапе сборки: runtime-проверка нужна потому, что HTTP приносит данные из-за границы процесса. В реальном клиенте результат следует связать с error boundary, telemetry без payload и понятным сообщением для пользователя. Самое важное — не передавать невалидное значение в компонент, который считает его достоверным.'),
|
||||
p('Не стоит делать validator чрезмерно умным. Он не должен сверять бизнес-правила, запрашивать второй endpoint или самостоятельно исправлять поле. Если <code>status</code> пришёл как <code>"ready "</code>, trim может скрыть нарушение контракта. Исправление допустимо на границе, только если это явно часть формата: например, нормализация регистра для заголовка. Чем больше молчаливых преобразований, тем труднее понять, что на самом деле отправил сервер.'),
|
||||
h2('204, JSON и пустое тело'),
|
||||
p('Классическая ошибка — общий helper, который всегда вызывает <code>response.json()</code>. Для 204 это некорректное ожидание: успешный ответ не обязан иметь representation. Команда «удалить» может завершиться 204 и потребовать повторного чтения списка; команда «получить экран» обычно возвращает представление. Эти два случая нельзя различать по URL или по тому, что parser иногда падает. Правило должно быть частью операции.'),
|
||||
p('Заголовок <code>Content-Type</code> тоже не подтверждает, что JSON валиден. Он сообщает заявленный формат, а не соответствие вашему screen contract. Поэтому проверка media type — ранняя ветка, а runtime validation — следующая. Ошибку формата следует отделять от сетевой ошибки: повтор запроса не исправит payload, который сервер стабильно формирует неправильно.'),
|
||||
h2('Рантайм-проверка и OpenAPI'),
|
||||
p('OpenAPI удобна как единый источник описания HTTP-интерфейса: она помогает связать ответ операции с компонентной схемой и генерировать типы. Но сгенерированный TypeScript-интерфейс не проверяет JSON во время выполнения. Если сервисы релизятся независимо, нужен контрактный тест или runtime validator на границе. Иначе компилятор подтвердит только то, что разработчик написал в исходниках.'),
|
||||
p('JSON Schema может описать типы, обязательность и перечисления, а валидатор — применить эту схему к фактическому payload. В небольшом клиенте допустима ручная функция, как в примере, если у неё есть тесты на неизвестный статус, неправильный массив и пустой message code. Выбор библиотеки — вторичен. Сначала зафиксируйте, какую ошибку должен увидеть пользователь и какие данные нельзя пропускать в view.'),
|
||||
h2('Как внедрить границу без большого переписывания'),
|
||||
ol([
|
||||
'Выбрать один будущий screen scenario и прямо записать: это план, а не описание уже существующего UI.',
|
||||
'Выписать local state отдельно от context; у каждого элемента назвать допустимое время жизни.',
|
||||
'Для доменного факта сформулировать один read question и один command intent без слов «обновить store».',
|
||||
'Согласовать минимальный screen read model: status, allowed actions, message code и named problem envelope.',
|
||||
'Прогнать fixed literal с domain claim; при stop удалить claim, а не добавлять ещё один optimistic flag.',
|
||||
'Передать только synthetic review card с открытыми вопросами владельцу следующего design review.',
|
||||
'Найдите один endpoint, где UI уже угадывает поля или использует fallback после исключения parser.',
|
||||
'Выпишите screen model из фактических решений компонента: состояние, доступные действия и сообщение.',
|
||||
'Добавьте проверки статуса и Content-Type до разбора тела; отдельно обработайте 204.',
|
||||
'Добавьте runtime validation для обязательных полей и неизвестных enum-значений.',
|
||||
'Напишите четыре теста: валидный ответ, неизвестный статус, неверный тип actions и успешный ответ без JSON.',
|
||||
'Покажите пользователю безопасное состояние ошибки, а в технический канал передайте только код нарушения и request id.',
|
||||
]),
|
||||
h2('Границы плана и следующий шаг'),
|
||||
p('Карта не доказывает, что конкретный endpoint хорошо спроектирован, и не сообщает о latency, правах реальных пользователей или поведении браузера. Она также не решает offline, cache invalidation и composition нескольких aggregates. JSON Schema Core объясняет, как фиксировать структуру документа, но не превращает validation в доменную корректность. Все перечисленные вопросы потребуют отдельного входа и собственных критериев.'),
|
||||
p('Следующий шаг на сентябрь — завести второй named literal, в котором у read model нет <code>allowedActions</code>, и убедиться, что review останавливается как на неполной модели. Так команда проверит не только удачную карту, но и язык отказа. До этого нельзя делать вывод о реальном frontend, backend или состоянии выбора: в пакете есть только изолированная модель и план её обсуждения.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Валидатор не доказывает, что значение истинно в домене, и не заменяет authorization. Он проверяет форму ответа и право клиента использовать эту форму. Он также не решает миграцию старого поля: для этого нужны версия схемы, совместимый период и тесты обеих сторон. Если один endpoint обслуживает несколько экранов, лучше назвать две read model, чем снова передать в UI универсальный объект.'),
|
||||
p('Следующий практический шаг — добавить в контракт тест с неизвестным статусом и проверить, что компонент не показывает «готово» по умолчанию. После этого полезно сравнить generated types с runtime schema и зафиксировать расхождение в CI. Важный результат — не ещё один helper, а видимая граница, после которой UI работает только с проверенной моделью.'),
|
||||
h2('Проверяемые источники'), sourceList([
|
||||
{ key: 'openapi', use: 'Использован узко: OAS определяет язык-независимое описание HTTP API, поэтому contract можно обсуждать отдельно от реализации.', boundary: 'Не доказывает, что какой-либо endpoint или screen model существует.' },
|
||||
{ key: 'http', use: 'Использован только для различения HTTP semantics и project-level problem envelope.', boundary: 'Не задаёт retry policy и не подтверждает исход конкретного запроса.' },
|
||||
{ key: 'schema', use: 'Использован для мысли о явной структуре документа и vocabulary.', boundary: 'Schema validation не доказывает доменную корректность или UI-поведение.' },
|
||||
{ key: 'http', use: 'Использован для различения семантики статус-кода и содержимого representation.', boundary: 'Не описывает screen model конкретного продукта и не заменяет runtime validation.' },
|
||||
{ key: 'openapi', use: 'Использован как формат описания HTTP-операции и схемы ответа.', boundary: 'Сгенерированный тип не является проверкой фактического JSON.' },
|
||||
{ key: 'problem', use: 'Использован для идеи структурировать ошибки HTTP отдельным типом.', boundary: 'Не назначает локальные коды UI и не выбирает retry policy.' },
|
||||
]),
|
||||
].join(''),
|
||||
};
|
||||
|
||||
const mechanism = {
|
||||
...commonMeta, slug: 'editorial-2026-09-mechanism-frontend-backend-boundary',
|
||||
title: 'План на сентябрь: на что boundary даёт право сделать вывод',
|
||||
excerpt: 'Как отличить query, command, read model и error envelope, чтобы UI не превращал транспортный ответ в неподтверждённый доменный вывод.',
|
||||
readingMinutes: 13,
|
||||
tags: ['frontend', 'backend', 'http', 'errors'],
|
||||
slug: 'editorial-2026-09-mechanism-frontend-backend-boundary',
|
||||
title: 'Состояние экрана не угадывают по статусу: разделяем query, command и ошибку',
|
||||
excerpt: 'Практическая схема для UI и BFF: как не превращать 409, 422, 429 и 500 в один красный toast и не терять следующий шаг.',
|
||||
contentHtml: [
|
||||
p('Сентябрьский механизм начинается с неприятного симптома: один handler отправляет запрос, меняет локальный флаг и затем трактует любой ответ как подтверждение. Проблема не в количестве hooks. Проблема в незаконном выводе: UI делает доменное утверждение из факта отправки команды. Цена — не только неверная кнопка. При повторе, параллельном клиенте или ошибке команда перестаёт быть различимой, а support получает сообщение без понятной причины и следующего действия.'),
|
||||
p('Это план для источников, известных на 31 июля 2026, а не отчёт о сентябрьской переделке. Он разделяет четыре формы: query запрашивает именованное представление, command выражает намерение, read model даёт экрану основания для показа, error envelope ограничивает реакцию. Ни одна форма не заменяет другую. Особенно опасно называть command «query с side effect» или считать status code готовой инструкцией для UI.'),
|
||||
h2('Command не является правом изменить экран'),
|
||||
p('Command полезен, когда его имя описывает намерение, а не способ работы с данными. В fixed literal это <code>fixed-submit-choice</code> с intent <code>set-choice</code> и named idempotency key. Имя не говорит, что выбор уже принят; оно лишь делает повторное намерение различимым в границе модели. Если name, intent или idempotency отсутствуют, review возвращает stop. Он не генерирует fallback и не пытается угадать безопасное значение.'),
|
||||
p('Query в этом сценарии — вопрос о view, а не скрытая команда. Его ответом становится screen read model <code>fixed-choice-screen-v1</code>. Поля <code>status</code>, <code>allowedActions</code> и <code>messageCode</code> образуют достаточное основание для отображения; каждое имеет читателя в UI. Если добавить поле «для будущего», но не назвать, какой вывод оно поддерживает, оно не улучшает contract. Оно расширяет поверхность совместимости без проверяемой задачи.'),
|
||||
figure('/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg', 'Матрица из четырёх строк: query читает screen model, command передаёт intent, read model даёт status и allowed actions, error envelope даёт code и retry. Внизу красная строка запрещает UI делать доменный вывод из отправки команды.', 'Матрица показывает разные права на вывод: отправка command не равна новому domain fact.'),
|
||||
table('Четыре формы на boundary', ['Форма', 'Кто формулирует', 'Что может заключить UI', 'Чего заключать нельзя'], [
|
||||
['query', 'view contract', 'какой named model нужен сейчас', 'что домен изменился'],
|
||||
['command', 'intent contract', 'какое намерение выражено', 'что намерение принято'],
|
||||
['read model', 'API boundary', 'что показать и какие action названы', 'внутреннюю причину без поля'],
|
||||
['problem envelope', 'error contract', 'какой message code и retry boundary есть', 'что ошибка transient без правила'],
|
||||
p('Проблема начинается, когда UI трактует любой ответ не-200 как «сервер упал»: пользователь получает бесполезный toast, а команда теряет причину отказа. Обратная крайность не лучше: компонент считает каждый 2xx подтверждением операции и показывает новое состояние до чтения модели. Цена такой путаницы — повторные команды, неверные подсказки и разбор инцидента по снимкам интерфейса вместо точного HTTP-контракта.'),
|
||||
p('Надёжная граница разделяет три намерения: query читает representation, command просит изменить состояние, а error envelope объясняет, почему переход не состоялся или что делать дальше. Статус HTTP — важная часть решения, но он не должен единолично выбирать текст, кнопку повторить и новый screen state. Ниже — таблица семантик и чистая функция, которую можно покрыть тестами без браузера.'),
|
||||
h2('Один ответ — несколько уровней смысла'),
|
||||
p('Запрос к экрану и команда изменения могут использовать один транспорт, но у них разные последствия. Query обычно превращает валидное представление в UI. Command сначала подтверждает, что запрос принят на уровне операции, а затем либо возвращает новую модель, либо требует повторного чтения. Если эти пути слить в один handler, команда легко станет локальным флагом «успешно», хотя сервер вернул только принятие запроса.'),
|
||||
figure('/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg', 'Матрица связывает HTTP-результат с правом UI: 2xx даёт право читать representation, 409 и 422 — показать исправляемую причину, 429 и 5xx — применить отдельную политику повторов, а отправка команды сама по себе не меняет screen state.', 'У каждого результата есть следующий шаг и запрет. Это меньше похоже на универсальный обработчик, зато не скрывает смысл ответа.'),
|
||||
table('Решение по HTTP-результату', ['Результат', 'Что можно заключить', 'Следующее действие UI', 'Чего нельзя делать'], [
|
||||
['200 + valid JSON', 'representation соответствует схеме', 'обновить view из модели', 'добавлять локальные доменные поля'],
|
||||
['204', 'операция завершилась без representation', 'инвалидировать или перечитать ресурс', 'вызывать JSON parser'],
|
||||
['409', 'текущее состояние конфликтует с командой', 'показать причину и предложить перечитать', 'повторять без изменения входа'],
|
||||
['422', 'вход не прошёл прикладную проверку', 'подсветить исправляемые поля', 'называть это сетевым сбоем'],
|
||||
['429 / 5xx', 'возможен временный отказ', 'использовать явную retry policy', 'делать бесконечный retry'],
|
||||
]),
|
||||
h2('Право на вывод — более точный тест, чем «где код»'),
|
||||
p('В проекте можно разместить adapter в frontend repo и всё равно держать доменную границу корректной. И наоборот, endpoint на backend не спасает, если component сам вычисляет разрешённый переход. Поэтому на сентябрьской встрече полезно не спорить об именах слоёв, а взять каждую ветку и спросить: каким полем contract она обоснована? Если такого поля нет, ветка должна стать stop, запросом на новую модель или явным local interaction — но не скрытым business rule.'),
|
||||
p('Read model не обязана копировать aggregate и не обязана быть вечной. Она обязана быть решаемой для своего consumer. В примере отсутствие любого из трёх полей останавливает review с <code>stop-read-model-incomplete</code>. Это сознательно строгая учебная форма: она не заявляет, что три поля достаточны для всех экранов. Она фиксирует минимум для одного named synthetic scenario и оставляет расширение отдельным решением.'),
|
||||
h2('Error — контракт действия, а не украшение ответа'),
|
||||
p('Thrown value удобна внутри языка, но на boundary она не даёт потребителю стабильного вопроса. В плановой модели допустим только <code>named-problem</code> с присутствующими ключами <code>code</code> и <code>retry</code>. Значение <code>none</code> или <code>not-requested</code> — fixed literal, не наблюдение. Оно не сообщает, что в сентябре ошибок не будет и не моделирует retry сети. Оно заставляет автора назвать семантику до того, как UI начнёт угадывать её по тексту.'),
|
||||
p('HTTP status остаётся значимым, но не должен молча стать domain language. RFC 9110 описывает status codes и семантику сообщений; из него не следует, что конкретный product flow обязан показывать кнопку retry. Такая кнопка появляется только при named rule на boundary. Этот разрыв полезен: transport failures, validation problems и запрещённые действия перестают сливаться в один catch, а каждое расширение контракта имеет место для review.'),
|
||||
h2('Где механизм намеренно останавливает вывод'),
|
||||
p('Первый stop связан с неизвестным input. Функция сравнивает вход с finite набором named fixtures, а не пытается принять похожий object. Это ограничение похоже на избыточную осторожность, но оно защищает учебный пример от тихой эволюции: добавили поле, поменяли смысл <code>retry</code>, а старый reviewer всё ещё читает accepted status. Новый input должен получить имя и пройти тот же review. Так автор видит изменение контракта до того, как его назовут обратной совместимостью.'),
|
||||
p('Второй stop касается команды без idempotency boundary. Мы не утверждаем, что любой production command обязан иметь определённый ключ: это зависит от предмета и реализации. Однако для выбранного synthetic scenario повтор намерения является значимой частью разговора, поэтому пустое поле запрещает hand-off. Такой gate не проектирует storage и не выбирает HTTP header. Он лишь не позволяет тексту говорить «повтор безопасен», пока в модели не названо, что именно различает повтор.'),
|
||||
p('Третий stop возникает, когда read model не даёт decision fields. Это не требование возвращать все возможные данные. Скорее, это отрицание ложной экономии: нельзя заставить consumer догадаться о permitted action по отсутствию поля или по message text. Если action зависит от сложного правила, API может вернуть более точную проекцию; если правило локальное, его следует явно вынести в UI. В обоих случаях boundary становится предметом change, а не неявным соглашением между двумя ветками.'),
|
||||
p('Именно поэтому позитивный ответ механизма маленький. Он перечисляет, что named fields позволяют только <code>synthetic-boundary-review-hand-off</code>. У него нет слова success, нет timestamp и нет результата команды. Чем меньше такой output похож на status системы, тем труднее использовать его как декоративное доказательство. Для статьи о будущем это важнее эффекта «работающий пример»: пример должен проверять формулировку, а не имитировать эксплуатацию.'),
|
||||
h2('Буквальная проверка fail-closed'),
|
||||
code("import { createFixedBoundaryCase, explainFixedBoundaryInference } from './upgrade-2026-09.mjs';\n\nconst input = createFixedBoundaryCase('unactionable-error-v1');\nconst result = explainFixedBoundaryInference(input);\nconsole.log({ status: result.status, reason: result.reasons[0], next: result.nextAction });\n// { status: 'stop-error-not-actionable', reason: 'error-is-not-a-named-problem-envelope', next: 'name-problem-code-and-retry-boundary' }"),
|
||||
p('Код исполняется только над зафиксированным literal в памяти и возвращает deterministic stop. Он не делает HTTP-вызов, не читает trace и не создаёт реальное исключение. Даже accepted branch выдаёт лишь synthetic hand-off. Это важная дисциплина для текста о будущем: «модель допускает передачу review» и «команда внедрила boundary» — разные предложения; второе у этого пакета не имеет источника и поэтому запрещено.'),
|
||||
p('Полезно отдельно проверить, не подменяет ли модель failure mode термином transport. Например, <code>named-problem</code> не обязан быть сериализацией RFC status и не обязан нести stack. Его задача в выбранном сценарии — сохранить код и границу retry, достаточные для следующей UI-ветки. Если consumer требуется причина для журнала, это новый contract question с собственным доступом и privacy boundary. Добавлять её «на всякий случай» в screen model нельзя: поля без читателя быстро становятся неявным интерфейсом для чужой логики. В частности, message code не должен превращаться в место, куда UI складывает технический текст, если contract не назвал его читателя и срок жизни.'),
|
||||
h2('Как проводить design review в сентябре'),
|
||||
h2('Conflict, validation и server failure'),
|
||||
p('409 Conflict — не синоним 500. Он сообщает, что запрос нельзя завершить в текущем состоянии ресурса; UI часто может перечитать модель, показать конфликт или попросить пользователя выбрать действие. 422 удобно использовать для валидного по синтаксису, но неприемлемого по правилам входа. Смысл ответа зависит от API, поэтому клиент должен читать структурированный problem detail, а не выводить причину из одной цифры.'),
|
||||
p('429 говорит о временном ограничении частоты, а 5xx — о проблеме на стороне сервера или его зависимости. Оба случая могут быть повторяемыми, но не одинаково: Retry-After, идемпотентность команды, бюджет попыток и состояние формы должны быть явными. Автоматический retry POST без idempotency boundary способен создать две операции. «Повторить запрос» — это политика, а не универсальная реакция на красный статус.'),
|
||||
h2('Problem Details как envelope'),
|
||||
p('RFC 9457 предлагает формат Problem Details с полями вроде <code>type</code>, <code>title</code>, <code>status</code>, <code>detail</code> и <code>instance</code>. Он не диктует, какой текст показывать и можно ли повторять команду. Приложение может добавить ограниченный <code>code</code> и список безопасных действий, если это описано его контрактом. Важно отделить машинный тип от свободного detail: последний может быть непригоден для локализации или содержать внутреннюю информацию.'),
|
||||
p('UI должен преобразовать envelope в свою модель сообщения один раз. Например, <code>order.version-conflict</code> превращается в «Обновить данные» и кнопку перечитать, а <code>order.invalid-address</code> — в подсветку поля. Компоненты не должны сравнивать строки title и detail. Это делает поведение устойчивым к переводу, изменению формулировки и разделению API между несколькими клиентами.'),
|
||||
h2('Воспроизводимая классификация'),
|
||||
code("import { classifyHttpResponse } from './upgrade-2026-09.mjs';\n\nconsole.log(classifyHttpResponse({\n status: 409,\n contentType: 'application/problem+json',\n body: { type: 'https://example.test/problems/version-conflict' },\n}));\n// { kind: 'domain-rejection', next: 'read-problem-type-and-show-recoverable-action' }\n\nconsole.log(classifyHttpResponse({\n status: 200, contentType: 'application/json',\n body: { status: 'ready', allowedActions: ['edit'], messageCode: 'order.ready' },\n}));\n// { kind: 'screen-model-ready', next: 'render-from-contract' }"),
|
||||
p('Функция классифицирует только названные свойства наблюдения. Она не объявляет операцию успешной по наличию поля <code>detail</code> и не делает retry автоматически. Для приложения это удобная точка тестирования: одна таблица входов проверяет, что 409 не попал в ветку network failure, а 200 с неподходящей model не прошёл в render.'),
|
||||
p('В реальном клиенте стоит добавить request id к технической записи и убрать из неё тело problem detail, если оно может содержать пользовательский ввод. Для пользователя нужны локализованный message code и один следующий шаг. Такой минимум лучше длинного сообщения, которое перечисляет внутренний stack trace и оставляет человека без решения.'),
|
||||
h2('Query и command не обязаны возвращать одно и то же'),
|
||||
p('После команды возможны три формы: новая screen model в ответе, 202 с идентификатором отслеживания или 204 без представления. Нельзя написать общий handler «после POST обновить store» и считать задачу решённой. Для 202 нужна отдельная модель состояния операции и правило polling или push. Для 204 нужно определить, какой ресурс инвалидировать и когда перечитать его. Контракт должен назвать форму, иначе каждый клиент изобретёт свою.'),
|
||||
p('Идемпотентность относится к смыслу команды, а не к тому, как выглядит кнопка. Повтор с тем же ключом может вернуть тот же результат или безопасно сообщить о предыдущем выполнении; без такой договорённости retry после timeout не даёт клиенту права считать, что команда не дошла. Timeout — это неизвестный исход, а не отрицательный исход. UI должен показать это различие и дать безопасный путь синхронизации.'),
|
||||
h2('Шесть тестов, которые окупаются'),
|
||||
ol([
|
||||
'Назвать один сценарий и дату: это план на сентябрь, состояние источников ограничено 31 июля.',
|
||||
'Записать command как intent с key для различимости повторов, не как обещание успешной мутации.',
|
||||
'Сформулировать query и screen read model через выводы, которые реально нужны view.',
|
||||
'Для каждой UI-ветки указать поле contract; ветку без поля заменить stop question.',
|
||||
'Описать problem envelope отдельно от HTTP-статуса и назвать retry boundary.',
|
||||
'Прогнать negative fixed literal и передать только synthetic hand-off с open question.',
|
||||
'Валидный 200 с полной screen model: компонент получает ровно разрешённые действия.',
|
||||
'200 с неизвестным status: render не вызывается, ошибка формы отделена от transport.',
|
||||
'204: parser не запускается, ресурс помечается для повторного чтения.',
|
||||
'409 с problem type: пользователь получает действие перечитать, а не автоматический бесконечный retry.',
|
||||
'422 с кодом поля: ошибка привязывается к input, а не к общему toast.',
|
||||
'429 и 500: политика повторов ограничена бюджетом и учитывает идемпотентность команды.',
|
||||
]),
|
||||
h2('Граница источников и граница механизма'),
|
||||
p('OpenAPI 3.1.1 поддерживает идею явного описания операции и сообщений, но не вводит наше разделение local/context/domain state. JSON Schema Core поддерживает vocabulary и ссылки в schema, но не решает, какие fields нужны человеку на экране. Эти решения — проектная модель P103. Review отмечает это отдельно, чтобы ссылкой на стандарт не прикрыть архитектурный вкус как доказанный факт.'),
|
||||
p('Механизм не покрывает streaming updates, eventual consistency, cache coherence, авторизацию и финансовую идемпотентность. Также он не измеряет удобство API и не обещает уменьшение числа ошибок. Для таких утверждений нужны реальные наблюдения, которых fixed literal не содержит. Следующий шаг — создать named case с корректным command, но без <code>messageCode</code>, и проверить точность stop reason; только потом обсуждать, нужен ли новый contract field.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Классификатор не проектирует бизнес-статусы и не заменяет спецификацию API. Один и тот же HTTP-код может иметь разный прикладной смысл для разных операций; поэтому таблицу нужно привязать к конкретному контракту. Problem Details тоже не решает authorization, приватность и наблюдаемость. Он даёт форму envelope, а не готовую политику продукта.'),
|
||||
p('Следующий шаг — выбрать один command с потенциальным повтором, добавить в контракт ответ при неизвестном исходе и написать тест на timeout. Если после этого UI всё ещё меняет state до query, граница не закончена. Нужен не новый флаг, а явный переход: команда отправлена, результат неизвестен, read model перечитана или получен problem detail.'),
|
||||
h2('Проверяемые источники'), sourceList([
|
||||
{ key: 'openapi', use: 'Использован для узкой роли: операция и сообщения могут быть описаны как интерфейс HTTP API.', boundary: 'Не устанавливает наши правила command/query или состав screen model.' },
|
||||
{ key: 'http', use: 'Использован для разграничения HTTP semantics и application-level problem policy.', boundary: 'Не подтверждает retry, error rate или итог конкретного обмена.' },
|
||||
{ key: 'schema', use: 'Использован как пример явной vocabulary contract-документа.', boundary: 'Не выводит business meaning из валидности JSON.' },
|
||||
{ key: 'http', use: 'Использован для семантики классов HTTP-ответов и различия transport/application meaning.', boundary: 'Не назначает конкретные статусы для бизнес-операции.' },
|
||||
{ key: 'problem', use: 'Использован для структуры Problem Details и разделения machine type и human detail.', boundary: 'Не определяет локализацию, retry или доступность действия.' },
|
||||
{ key: 'openapi', use: 'Использован для идеи явно описывать response variants у операции.', boundary: 'Не гарантирует, что реализация и фактический payload совпадают.' },
|
||||
]),
|
||||
].join(''),
|
||||
};
|
||||
|
||||
const field = {
|
||||
...commonMeta, slug: 'editorial-2026-09-field-frontend-backend-boundary',
|
||||
title: 'Сценарий на сентябрь: synthetic hand-off для границы UI и API',
|
||||
excerpt: 'Как подготовить безопасную карточку review без изображения production trace, чтобы несогласованный ownership останавливал обсуждение точной причиной.',
|
||||
readingMinutes: 14,
|
||||
tags: ['frontend', 'backend', 'debugging', 'http'],
|
||||
slug: 'editorial-2026-09-field-frontend-backend-boundary',
|
||||
title: 'UI и API расходятся: полевой протокол диагностики без догадок',
|
||||
excerpt: 'Пошаговый разбор рассинхрона: какие четыре факта снять на границе, как отличить stale state от неверного ответа и где остановить расследование.',
|
||||
contentHtml: [
|
||||
p('В сентябрьском сценарии проблема review часто не в отсутствии диаграммы, а в подмене evidence. Автор показывает «типичный ответ» и произносит, что экран уже согласован с API, хотя пример не имеет владельца, даты и отрицательной ветки. Цена такой уверенности — решение принимают по иллюстрации, которую нельзя повторить: при первом расхождении UI и backend спорят, была ли это договорённость или просто локальный mock.'),
|
||||
p('Этот материал — план безопасного synthetic hand-off на сентябрь 2026 при состоянии источников на 31 июля. Он не описывает проведённый review и не имитирует UI-network trace. Вместо него используется named fixed literal с заранее известными полями. Положительный исход ограничен передачей карточки следующему reviewer; он не утверждает approval, deployment, публикацию или доступ к production.'),
|
||||
h2('Карточка evidence должна содержать то, что может опровергнуть вывод'),
|
||||
p('Поле <code>ui.localState</code> показывает, что interaction принадлежит экрану; <code>ui.context</code> фиксирует условия интерпретации; <code>api.command</code> называет намерение; <code>api.readModel</code> задаёт достаточное представление; <code>api.error</code> ограничивает реакцию. Эти поля не являются дампом данных. Они составлены так, чтобы reviewer мог отвергнуть карточку по одному отсутствующему условию, не достраивая его опытом или знанием настоящей системы.'),
|
||||
p('Контрпример важнее красивой positive ветки. Если UI уже содержит <code>choice-confirmed</code> в <code>forbiddenDomainClaims</code>, функция не говорит «почти готово». Она возвращает <code>stop-ui-claims-domain-state</code>. Это не обвинение frontend и не факт о реальном приложении. Это свойство формального literal: boundary не позволяет выводу, пока claim не заменён read question или не закреплён соответствующим контрактом.'),
|
||||
figure('/assets/editorial/2026/frontend-backend-boundary-2026-review-evidence-loop.svg', 'Цикл из пяти шагов: fixed literal, проверка ownership, stop или counterexample, уточнённый literal и synthetic hand-off. Красная ветка от domain claim возвращает к вопросу о read model.', 'Evidence loop останавливает недоказанный вывод и возвращает его к именованному входу, а не к реальному окружению.'),
|
||||
table('Поля безопасной hand-off карточки', ['Поле', 'Зачем оно нужно', 'Пример fixed value', 'Что оно не доказывает'], [
|
||||
['localState', 'отделить interaction от domain', 'draft-filter', 'что пользователь действительно вводил фильтр'],
|
||||
['context', 'назвать условие интерпретации', 'fixed-locale', 'реальное permission или session'],
|
||||
['command', 'зафиксировать intent', 'fixed-submit-choice', 'успешное изменение'],
|
||||
['readModel', 'дать основание UI-ветке', 'allowedActions', 'состояние настоящего экрана'],
|
||||
['problem', 'сохранить stop semantics', 'named-problem', 'наличие сетевой ошибки'],
|
||||
p('Симптом «кнопка показывает одно, API — другое» редко объясняется одной строкой. В браузере мог остаться старый store, прокси мог вернуть не тот Content-Type, команда могла завершиться 409, а компонент — считать любой ответ подтверждением. Цена бессистемной диагностики — повторные ручные проверки и исправление не той стороны: команда меняет сервер, когда проблема в render input, или переписывает UI, когда контракт уже нарушен.'),
|
||||
p('Нужен короткий протокол, который фиксирует наблюдаемые факты в правильном порядке: пользовательское намерение, запрос, ответ и фактический вход в рендер. Нельзя начинать с гипотезы «кэш виноват» или «backend сломался». В статье соберём безопасную карточку расследования, дадим чистую функцию для сравнения данных и покажем, какие выводы разрешены на каждом шаге.'),
|
||||
h2('Четыре снимка одной операции'),
|
||||
p('Первый снимок — намерение: какой action выбрал пользователь и с какими нормализованными параметрами. Не нужно сохранять весь ввод; достаточно request id, operation name и безопасного класса входа. Второй — исходящий HTTP: method, route template, статус и заголовок correlation/request id без query-параметров и секретов. Третий — ответ: status, Content-Type, размер и проверенная форма тела. Четвёртый — объект, который реально получил компонент после adapter.'),
|
||||
figure('/assets/editorial/2026/frontend-backend-boundary-2026-review-evidence-loop.svg', 'Цикл диагностики из четырёх снимков: intent, request, response, render input. Ветви ведут к stale UI, нарушению контракта или корректному обновлению.', 'Сравнивать нужно соседние границы. Снимок сети без render input не объясняет, почему экран остался прежним.'),
|
||||
table('Карточка диагностики рассинхрона', ['Факт', 'Минимальное поле', 'Безопасный пример', 'Какой вопрос закрывает'], [
|
||||
['Intent', 'operation + input class', 'update-address / valid-form', 'какую команду хотел выполнить UI?'],
|
||||
['Request', 'method + route template + request id', 'PATCH /orders/:id + req-42', 'что действительно ушло за границу?'],
|
||||
['Response', 'status + media type + contract result', '409 + problem + valid envelope', 'что сервер сообщил на уровне контракта?'],
|
||||
['Render input', 'view model + revision', 'blocked + version 18', 'что именно получил компонент?'],
|
||||
['Decision', 'next action', 'refetch / fix field / inspect adapter', 'какой следующий тест даст различие?'],
|
||||
]),
|
||||
h2('Что именно является evidence в synthetic работе'),
|
||||
p('Evidence здесь — не лог браузера и не screenshot. Это связь между одним известным входом и результатом pure function. Вход создаётся JSON clone и deep freeze, поэтому caller не может тихо изменить fixture между проверкой и hand-off. Determinism полезен ровно в своей области: reviewer может запустить public export и получить тот же named stop или тот же limited hand-off. Он не может из этого узнать задержку сети, порядок событий или поведение реального кода.'),
|
||||
p('Отдельно полезно писать границу прямо в output: <code>productionEffect: not-attempted</code>. Обычно такую строку считают избыточной, пока positive status не начинают читать как разрешение. Здесь статус <code>synthetic-boundary-review-hand-off</code> означает только полноту заранее заданной структуры. Он не утверждает, что ownership выбран верно для продукта, не меняет конфигурацию и не разрешает кому-либо отправлять command в настоящий сервис.'),
|
||||
h2('Почему fixed literal лучше «правдоподобного» примера'),
|
||||
p('Правдоподобный mock соблазняет деталями: URL, имя клиента, задержка, fragment response. Но чем точнее он похож на среду, тем проще читателю принять его за наблюдение. Для будущего месяца это особенно опасно: вымышленная trace визуально выглядит как выполненная работа. Named fixed literal отказывается от этой достоверности нарочно. Его values звучат искусственно — <code>fixed-locale</code>, <code>named-key-v1</code> — и этим не дают перепутать модель с фактом.'),
|
||||
p('JSON clone перед freeze нужен не для демонстрации приёма JavaScript, а для границы владения fixture. Проверяемая функция не получает ссылку на скрыто изменяемый внутренний объект. Она получает новый JSON-compatible input, после чего вложенные поля заморожены. Это не защищает реальную память процесса и не является security control. Это минимальное свойство учебного примера: один reviewer не может случайно изменить <code>allowedActions</code> перед передачей карточки другому.'),
|
||||
p('Отсутствие clock — ещё одна сознательная граница. В пакете нельзя сказать «после двух секунд» или «сначала пришёл response», потому что в literal нет времени и событий. Вместо таймлайна есть порядок review: вход, проверка, reason, next action. Такой порядок не претендует на causal trace. Он удобен, когда обсуждается качество контракта: причинность должна быть подтверждена отдельным наблюдением, а не дорисована последовательностью стрелок.'),
|
||||
p('Наконец, synthetic hand-off не является документом согласования между командами. Он только делает disagreement видимым. Реальный agreement потребовал бы участников, версий, полномочий и, вероятно, изменения описания API; ни одного такого факта P103 не содержит. Поэтому карточка завершает свою работу словом hand-off, а не «решение принято». Это сохраняет полезную дистанцию между подготовкой разговора и внешним действием.'),
|
||||
p('В поле особенно соблазнительно заменить отсутствие evidence словами «этот путь типичен». Для P103 это запрещённая экономия. Типичным может быть только named fixture, а не пользователь, экран, endpoint или последовательность пакетов. Если следующий reviewer хочет проверить реальный boundary, он должен создать отдельную задачу с разрешёнными inputs и получить доступ к ним в своём процессе. Синтетическая карточка не переносит ему ни данных, ни полномочий, зато точно фиксирует, какую неизвестность нельзя прятать за правдоподобным примером. Это сохраняет применимость материала: он учит форме вопроса, а не создаёт ложное знание о системе, которую не наблюдали.'),
|
||||
h2('Буквальный пример передачи, а не одобрения'),
|
||||
code("import { createFixedBoundaryCase, explainFixedBoundaryInference } from './upgrade-2026-09.mjs';\n\nconst input = createFixedBoundaryCase('named-screen-boundary-v1');\nconst result = explainFixedBoundaryInference(input);\nconsole.log({ status: result.status, next: result.nextAction, effect: result.productionEffect });\n// { status: 'synthetic-inference-boundary-explained', next: 'hand-off-fixed-synthetic-boundary-review', effect: 'not-attempted' }"),
|
||||
p('Это максимальный положительный выход P103. Snippet использует только named fixed literals; он не запускает UI, не читает файл, не обращается к секрету и не делает запрос. Слово hand-off здесь означает передачу формулировки для дальнейшего review, а не передачу контроля над средой. Такой узкий output делает полезной и negative ветку: при дефекте она сообщает ровно, какого поля не хватает, вместо декоративного «проверить интеграцию». '),
|
||||
h2('Как reviewer читает карточку'),
|
||||
p('Сначала он ищет ownership conflict: доменное утверждение в UI, контекст, спрятанный в read model, или command без intent. Затем он читает constraints у error envelope: есть ли code и retry boundary, а не просто строка ошибки. После этого он проверяет вывод: соответствует ли status одному из явно названных состояний функции. Если карточка просит сделать шаг в окружении, которого literal не касается, review обязан остановиться — даже если описание кажется правдоподобным.'),
|
||||
p('Эта процедура нарочно скупа на «истории успеха». В ней нет synthetic метрики, потому что числовой эффект легко спутать с наблюдением. В ней нет названия настоящей команды или endpoint, потому что их нельзя проверить из изолированного пакета. Вместо этого сохраняется контрпример с неназванной command и контрпример с неоперабельной ошибкой. Они учат лучше, чем happy path: показывают момент, когда автор обязан сменить вопрос.'),
|
||||
h2('Порядок сентябрьского hand-off'),
|
||||
h2('Сначала сравнить, потом объяснять'),
|
||||
p('Если response valid, а render input старый, ищите adapter, cache key или race между двумя запросами. Если render input совпадает с ответом, но на экране другое, проверяйте selector, memoization и локальный optimistic state. Если response invalid, UI не обязан его отображать; источник проблемы находится на контрактной границе. Если status 409, не называйте его «ошибкой сети»: запрос дошёл, но переход не состоялся по прикладной причине.'),
|
||||
p('Полезно сохранять revision или version, если API их предоставляет. Timestamp не заменяет версию: часы разных процессов могут отличаться, а более поздний ответ не всегда относится к более новому состоянию. Сравнение версии помогает обнаружить race: запрос A ушёл первым, B — вторым, но B вернулся раньше. Без явного правила store может принять старый ответ последним.'),
|
||||
h2('Проверяемый пример сравнения'),
|
||||
code("import { diagnoseBoundaryObservation } from './upgrade-2026-09.mjs';\n\nconsole.log(diagnoseBoundaryObservation({\n response: {\n status: 202,\n contentType: 'application/json',\n body: { status: 'blocked', allowedActions: ['retry'], messageCode: 'order.sync_required' },\n },\n ui: { renderedStatus: 'ready' },\n}));\n// { status: 'ui-api-state-mismatch', next: 'compare-render-input-with-response-body' }"),
|
||||
p('Эта функция не читает браузер и не делает сетевой запрос. Она полезна как тест для диагностического слоя: при одинаковом контракте она отличает расхождение render input от ошибки формы. В приложении фактические снимки нужно получать из инструментов с фильтрацией персональных данных. Не записывайте body целиком в лог только потому, что так проще: request id и hash безопасного класса часто достаточно, чтобы связать события.'),
|
||||
p('При отсутствии response функция возвращает отдельное состояние, а не «UI stale». Это важная дисциплина. Нельзя приписывать причину месту, которое ещё не наблюдали. Так же нельзя считать отсутствие нового render proof того, что backend не ответил: событие могло потеряться в adapter, отмениться при unmount или быть отброшено как устаревшее.'),
|
||||
h2('Сигналы cache и race'),
|
||||
p('Cache обычно выдаёт повторяемость: один и тот же ключ возвращает прежнюю версию, хотя network request уже получил новую. Race выдаёт зависимость от порядка: обновление состояния меняется при задержке одного из ответов. Для проверки cache сравните request key, revision и источник данных. Для race искусственно задержите только тестовый ответ и проверьте правило принятия версии. Не делайте вывод о поведении пользователя по одному снимку.'),
|
||||
p('Если команда использует optimistic UI, назовите два состояния: локальное подтверждение взаимодействия и подтверждённая read model. Пока ответ не прочитан, на кнопке допустимо показать spinner или disable, но нельзя менять общий status на «ready» только потому, что click handler завершился. После ошибки optimistic patch должен быть снят или помечен как требующий решения. Иначе следующий render унаследует ложную модель.'),
|
||||
h2('HTTP-инструменты и граница их показаний'),
|
||||
p('DevTools Network показывает запрос и ответ в конкретном браузере. <code>curl</code> позволяет повторить HTTP-обмен, но не воспроизводит selector, cookie policy или race компонента. Логи BFF показывают серверную обработку, но не доказывают, что браузер получил именно этот response. Каждый инструмент закрывает свою границу. Скриншот одного слоя нельзя использовать как доказательство другого.'),
|
||||
p('Для curl-проверки сохраняйте только безопасные заголовки и заменяйте реальные идентификаторы тестовыми. Проверяйте статус и Content-Type, затем тело на соответствие schema. Если endpoint требует авторизацию, используйте тестовый токен с ограниченным сроком; не вставляйте секрет в статью, shell history или issue. В production не повторяйте команду изменения без знания идемпотентности.'),
|
||||
h2('Пример минимальной curl-проверки'),
|
||||
code("curl --fail-with-body --silent --show-error \\\n -H 'Accept: application/json' \\\n -H 'X-Request-Id: req-42' \\\n 'https://api.example.test/orders/42' \\\n | jq '{status, allowedActions, messageCode}'"),
|
||||
p('Команда подходит для чтения тестового ресурса, а не для бездумного повторения mutation. <code>--fail-with-body</code> не валидирует screen model и не превращает ошибку в успех; <code>jq</code> только выбирает поля для просмотра. В рабочем расследовании замените host, путь и идентификатор на разрешённые тестовые значения и сохраните рядом HTTP-статус и Content-Type. Если ответ не JSON, это отдельная находка, а не повод считать jq виноватым.'),
|
||||
h2('Порядок расследования'),
|
||||
ol([
|
||||
'Пометить карточку как план/сценарий на сентябрь и записать cutoff источников 31.07.2026.',
|
||||
'Собрать один fixed literal с local state, context, command, read model и error envelope.',
|
||||
'Запустить positive literal и проверить, что output ограничен synthetic hand-off и not-attempted.',
|
||||
'Запустить literal с UI domain claim и сохранить exact stop reason.',
|
||||
'Запустить literal с пустым command или error envelope и не заменять stop догадкой.',
|
||||
'Передать reviewer только вход, output, границы и открытый вопрос; не добавлять claims о real UI/API.',
|
||||
'Зафиксировать operation name, request id и класс входа без персональных значений.',
|
||||
'Снять method, route template, HTTP status и Content-Type; не повторять mutation до проверки идемпотентности.',
|
||||
'Проверить response по контракту и отделить transport failure от problem detail и domain rejection.',
|
||||
'Сравнить проверенный response с render input: version, status, allowed actions и message code.',
|
||||
'Если они расходятся, проверить adapter, cache key, optimistic patch и порядок ответов.',
|
||||
'Сформулировать один следующий тест, который различает две оставшиеся гипотезы, и только затем менять код.',
|
||||
]),
|
||||
h2('Ограничения и следующий вопрос'),
|
||||
p('Synthetic hand-off не заменяет контрактное тестирование, доступность, security review и проверку authorization. Он не определяет, где хранить cache, как кодировать проблему на wire и как версии читать старым клиентам. OAS и JSON Schema помогают описывать форму, RFC 9110 — смысл HTTP, но ни один из этих источников не подтверждает, что предлагаемая форма полезна конкретному человеку. P103 сознательно не делает такого заявления.'),
|
||||
p('Следующий шаг — на отдельном design review создать ещё один fixed literal с известным несовпадением: command named, но read model без <code>allowedActions</code>. Если reason останется точным и не даст «продолжить на глаз», сценарий готов к обсуждению следующего контракта. Если нет, следует уточнить форму карточки. До независимой работы с настоящими inputs нельзя говорить, что сентябрьский boundary уже реализован, измерен или опубликован.'),
|
||||
h2('Когда остановить расследование'),
|
||||
p('Остановитесь, если для следующего вывода нужны неполученные данные: реальный body с персональными полями, доступ к production-логам или повторная команда с неизвестным эффектом. Это не бюрократия, а граница доказательства. Попросите владельца системы дать безопасный correlation id, redacted response или тестовый reproduction. Нельзя заполнять пробел правдоподобной историей о кэше или сервере.'),
|
||||
p('Если четыре снимка согласованы, а пользователь всё ещё видит другое, расследование переходит в слой представления: selector, memoization, hydration или CSS. Если несогласован только response, проблема остаётся у producer или gateway. Если request отличается от intent, ищите mapping формы. Такая классификация сокращает область поиска и делает исправление проверяемым.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Протокол не заменяет distributed tracing, contract testing и security review. Он не сообщает, что данные можно хранить сколько угодно, и не разрешает логировать тело ответа. Его задача уже: разделить наблюдения по границам и не назвать гипотезу фактом. Для сложной асинхронной команды добавьте message id, version и отдельный статус обработки; не растягивайте одну HTTP-карточку на очередь.'),
|
||||
p('Следующий шаг — сделать один интеграционный тест, который сохраняет четыре безопасных поля и воспроизводит stale render input. Затем добавьте regression test на неверный Content-Type и test на более старую version. Когда эти тесты проходят, команда получает не красивый отчёт, а короткий маршрут от симптома к конкретной границе.'),
|
||||
h2('Проверяемые источники'), sourceList([
|
||||
{ key: 'openapi', use: 'Использован только как первичный источник для понятия описываемого HTTP API interface.', boundary: 'Не является evidence настоящего hand-off или release.' },
|
||||
{ key: 'http', use: 'Использован для границы между protocol semantics и application-level decision.', boundary: 'Не сообщает о реальном response, user или сети.' },
|
||||
{ key: 'schema', use: 'Использован для идеи явно структурированной проверяемой карточки.', boundary: 'Не подтверждает достоверность значения внутри fixed literal.' },
|
||||
{ key: 'http', use: 'Использован для различения ответа HTTP, representation и прикладной семантики статусов.', boundary: 'Не показывает состояние конкретного браузера или store.' },
|
||||
{ key: 'fetch', use: 'Использован для границы между Fetch response и решением приложения об обработке body.', boundary: 'Стандарт не задаёт ваш adapter, cache policy или UI render.' },
|
||||
{ key: 'problem', use: 'Использован для структурированного problem envelope при диагностике 4xx/5xx.', boundary: 'Не является логом конкретного сервиса и не заменяет redaction policy.' },
|
||||
]),
|
||||
].join(''),
|
||||
};
|
||||
|
||||
export const revisions = [practice, mechanism, field];
|
||||
|
||||
export function verifyRevisionsAgainstFixture() {
|
||||
const fixture = runFixedBoundaryFixture();
|
||||
const articleChecks = revisions.map((revision) => { const text = bodyText(revision.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|издержк)/i.test(text.slice(0, 900)) && /<table>/.test(revision.contentHtml) && /<figure>/.test(revision.contentHtml) && /<pre><code>/.test(revision.contentHtml) && /<ol>/.test(revision.contentHtml); });
|
||||
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((revision) => [revision.slug, bodyText(revision.contentHtml).length])) });
|
||||
const checks = [
|
||||
checkScreenResponse({ status: 'ready', allowedActions: ['edit'], messageCode: 'order.ready' }).valid,
|
||||
!checkScreenResponse({ status: 'unknown', allowedActions: 'edit' }).valid,
|
||||
classifyHttpResponse({ status: 204 }).kind === 'success-without-representation',
|
||||
classifyHttpResponse({ status: 409, contentType: 'application/problem+json', body: {} }).kind === 'domain-rejection',
|
||||
diagnoseBoundaryObservation({ response: { status: 200, contentType: 'application/json', body: { status: 'ready', allowedActions: [], messageCode: 'ok' } }, ui: { renderedStatus: 'pending' } }).status === 'ui-api-state-mismatch',
|
||||
];
|
||||
const articleChecks = revisions.map((revision) => {
|
||||
const text = bodyText(revision.contentHtml);
|
||||
return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|издержк|затрат|потер)/i.test(text.slice(0, 900)) && /<figure>/.test(revision.contentHtml) && /<table>/.test(revision.contentHtml) && /<pre><code>/.test(revision.contentHtml) && /<ol>/.test(revision.contentHtml);
|
||||
});
|
||||
return {
|
||||
passed: checks.filter(Boolean).length + articleChecks.filter(Boolean).length,
|
||||
total: checks.length + articleChecks.length,
|
||||
accepted: checks.every(Boolean) && articleChecks.every(Boolean),
|
||||
checks,
|
||||
articleChecks,
|
||||
characters: Object.fromEntries(revisions.map((revision) => [revision.slug, bodyText(revision.contentHtml).length])),
|
||||
};
|
||||
}
|
||||
if (process.argv.includes('--verify-fixture')) { const report = verifyRevisionsAgainstFixture(); console.log(JSON.stringify(report, null, 2)); if (!report.accepted) process.exitCode = 1; }
|
||||
|
||||
if (process.argv.includes('--verify-fixture')) {
|
||||
const report = verifyRevisionsAgainstFixture();
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.accepted) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv.includes('--print-revisions')) console.log(JSON.stringify(revisions));
|
||||
|
||||
Reference in New Issue
Block a user