Files
progcode/web/scripts/upgrade-2027-09.mjs
2026-07-31 22:26:56 +03:00

281 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const escapeHtml = (value) => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
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((cell) => `<th scope="col">${cell}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.values(value).forEach(deepFreeze);
Object.freeze(value);
}
return value;
}
function plainText(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039;)/g, ' ').replace(/\s+/g, ' ').trim();
}
function bodyText(html) {
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/, ''));
}
const REFERENCES = deepFreeze({
openapi: { title: 'OpenAPI Specification 3.1.1', url: 'https://spec.openapis.org/oas/v3.1.1.html', version: 'OpenAPI Initiative, 24 октября 2024 года, версия 3.1.1' },
jsonSchema: { title: 'JSON Schema Core 2020-12', url: 'https://json-schema.org/draft/2020-12/json-schema-core.html', version: 'JSON Schema, draft 2020-12, спецификация Core' },
http: { title: 'RFC 9110 — HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110.html', version: 'IETF, июнь 2022 года, RFC 9110, Standards Track' },
});
function sources(entries) {
return `<ul>${entries.map(({ key, use, boundary }) => {
const ref = REFERENCES[key];
return `<li><a href="${ref.url}" target="_blank" rel="noopener noreferrer">${escapeHtml(ref.title)}</a> — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}</li>`;
}).join('')}</ul>`;
}
export function validateCustomerResponse(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { ok: false, reason: 'body-must-be-object' };
if (typeof payload.id !== 'string' || payload.id.length < 1) return { ok: false, reason: 'id-must-be-non-empty-string' };
if (!Number.isInteger(payload.revision) || payload.revision < 1) return { ok: false, reason: 'revision-must-be-positive-integer' };
if (!['active', 'blocked'].includes(payload.state)) return { ok: false, reason: 'state-is-outside-enum' };
return { ok: true, value: { id: payload.id, revision: payload.revision, state: payload.state } };
}
export function validateFilterInput(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return { ok: false, reason: 'filter-must-be-object' };
if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100)) return { ok: false, reason: 'limit-out-of-range' };
if (input.cursor !== undefined && (typeof input.cursor !== 'string' || input.cursor.length > 256)) return { ok: false, reason: 'cursor-invalid' };
if (input.state !== undefined && !['active', 'blocked'].includes(input.state)) return { ok: false, reason: 'state-is-outside-enum' };
return { ok: true, value: { limit: input.limit ?? 20, cursor: input.cursor ?? null, state: input.state ?? null } };
}
export function classifyApiChange(change) {
const removed = Array.isArray(change.removedProperties) ? change.removedProperties : [];
const addedRequired = Array.isArray(change.addedRequiredProperties) ? change.addedRequiredProperties : [];
const narrowedEnum = Boolean(change.narrowedEnum);
const status = removed.length > 0 || addedRequired.length > 0 || narrowedEnum ? 'breaking' : 'compatible';
return { status, action: status === 'breaking' ? 'version-or-expand-compatibility-window' : 'run-consumer-contract-tests' };
}
function revision(meta, parts, referenceEntries) {
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(`${meta.slug}: body length ${proseLength}`);
return deepFreeze({ ...meta, contentHtml, proseLength });
}
const contractRefs = [
{ key: 'openapi', use: 'Фиксирует структуру HTTP-интерфейса, операции, ответы и семантику описания, чтобы контракт был машинно читаемым.', boundary: 'Не доказывает, что сервер действительно отдаёт описанное тело: runtime-проверка и тесты остаются отдельной обязанностью.' },
{ key: 'jsonSchema', use: 'Задаёт язык типов, обязательных полей, ограничений и ветвления для JSON-документов.', boundary: 'Схема не знает бизнес-состояние, права доступа, задержку или согласованность нескольких запросов.' },
{ key: 'http', use: 'Разделяет метод, статус, представление ресурса и условия обмена, на которые опирается совместимость.', boundary: 'Не описывает локальную реализацию сервиса, формат внутренней базы или конкретный клиент.' },
];
const practice = revision({
slug: 'editorial-2027-09-practice-mentor-series',
title: 'API-контракт: как остановить несовместимый ответ до релиза',
categories: ['Backend', 'API'],
cover: '/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg',
excerpt: 'Разбираем контракт HTTP-ответа: какие изменения ломают клиента, как проверить их локальным валидатором и где заканчивается схема.',
readingMinutes: 14,
}, [
p('Проблема проявляется не в файле OpenAPI, а у потребителя: клиент получает 200, пытается прочитать поле и падает на обычном успешном ответе. Цена такой ошибки — не только один дефектный запрос. Нужно одновременно искать версию клиента, выяснять, какой ответ он ожидал, и решать, можно ли откатить сервер без потери данных.'),
p('Частая причина — считать добавление поля безопасным всегда или проверять только happy path. Несовместимыми бывают удаление свойства, добавление обязательного свойства, сужение enum и изменение типа. Ниже — узкий контракт для ответа клиента, чистая проверка входа и порядок, который позволяет увидеть риск до публикации изменения.'),
h2('Контракт начинается с формы ответа'),
p('Контракт — это не комментарий к контроллеру. Он отвечает на четыре вопроса: какой ресурс возвращён, какие поля обязательны, какие значения допустимы и как клиент понимает отказ. Если поле <code>state</code> раньше имело значения <code>active</code> и <code>blocked</code>, добавление <code>deleted</code> может сломать клиентский switch, даже если JSON остаётся валидным. Если поле стало числом вместо строки, ломается уже десериализация.'),
p('OpenAPI удобно держать источником формы интерфейса, а JSON Schema — точным описанием JSON-части. Но схема не проверит право пользователя и не узнает, что ревизия записи уже устарела. Поэтому в статье разделены синтаксический контракт и бизнес-проверка: первый должен быть быстрым и детерминированным, вторая живёт рядом с доменным кодом и тестируется отдельно.'),
table('Изменение ответа и риск для клиента', ['Изменение', 'Тип риска', 'Проверка перед выпуском', 'Безопасное действие'], [
['Добавлено необязательное поле', 'Обычно совместимо', 'Старый клиент игнорирует поле', 'Добавить contract-test на старую форму'],
['Удалено поле', 'Breaking', 'Поиск чтения поля в клиентах', 'Сначала deprecated-окно, затем удаление'],
['Добавлено обязательное поле', 'Breaking для отправителя', 'Проверить все request/response builders', 'Сделать поле optional или выпустить версию'],
['Сужен enum', 'Breaking для ветвлений', 'Прогнать все старые значения', 'Сохранить значение либо объявить несовместимость'],
['Изменён тип', 'Breaking', 'Сериализация и fixture ответа', 'Добавить новое поле с новым именем'],
]),
h2('Маленький контракт лучше общего обещания'),
p('Возьмём ответ <code>GET /customers/{id}</code>. Клиенту нужны строковый идентификатор, положительная ревизия и закрытый набор состояний. Валидатор не обращается к сети и не угадывает отсутствующие данные. Он принимает JSON-представление, возвращает нормализованный набор полей или ясную причину отказа. Это полезно в unit-тесте, в consumer contract test и в адаптере на границе сервиса.'),
p('Важно не путать нормализацию с исправлением. Значение <code>limit</code> можно подставить по умолчанию только там, где это прямо разрешено контрактом фильтра. Для ответа клиента отсутствие обязательного <code>revision</code> — ошибка, а не повод поставить единицу. Молчаливое исправление скрывает несовместимость и переносит её на более дорогой этап.'),
figure('/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg', 'Диаграмма API-контракта: JSON-ответ проходит через проверку обязательных полей, перечислений и типа, после чего клиент получает совместимое представление или ясный отказ.', 'Схема показывает границу между описанием ответа, проверкой формы и действием клиента. Она не обещает, что проверка заменяет бизнес-правила или интеграционные тесты.'),
h2('Runnable-пример: проверяем вход и ожидаемый результат'),
p('Пример запускается в Node.js и вызывает экспортированную функцию с двумя объектами. Входом служит обычный JavaScript-объект, а результатом — <code>ok: true</code> с нормализованным значением или <code>ok: false</code> с конкретной причиной. В учебном примере нет HTTP-сервера: цель — показать поведение контракта на границе, а не изобразить готовый production-adapter.'),
code(`import { validateCustomerResponse } from './upgrade-2027-09.mjs';
const accepted = validateCustomerResponse({
id: 'customer-17',
revision: 4,
state: 'active',
});
const rejected = validateCustomerResponse({
id: 'customer-17',
revision: 4,
state: 'deleted',
});
console.log(accepted.ok, accepted.value.state);
console.log(rejected.ok, rejected.reason);
// true active
// false state-is-outside-enum`),
h2('Порядок проверки изменения'),
ol([
'Сначала назовите endpoint, метод, статус и media type. Без этого слово «контракт» смешивает запрос, ответ и внутреннюю модель.',
'Снимите текущую форму ответа: обязательные поля, типы, enum, nullable и значения по умолчанию. Зафиксируйте один положительный и несколько отрицательных примеров.',
'Сравните diff схемы с реальными местами чтения. Особенно ищите удаление поля, изменение типа и сужение перечисления.',
'Запустите детерминированный валидатор на старой и новой форме. Ошибка должна содержать поле и причину, а не общий «invalid response».',
'Прогоните consumer contract tests для двух соседних версий клиента. Если старый клиент не проходит, выберите новое поле, совместимое расширение или отдельную версию.',
'После выпуска добавьте срок удаления deprecated-поля и проверяемый сигнал использования. Не удаляйте его по ощущению, если нет данных о потребителях.',
]),
h2('Где заканчивается схема'),
p('Схема не отвечает на вопрос, можно ли изменить запись. Ответ <code>state: active</code> может быть формально правильным, но устаревшим относительно команды обновления. Для этого нужны версия ресурса, условный запрос вроде <code>If-Match</code>, правила авторизации и транзакционная проверка. Эти условия следует описывать рядом с endpoint, но не выдавать за свойства JSON Schema.'),
p('Схема также не гарантирует одинаковое поведение всех реализаций. Сервер может вернуть правильный JSON только для одного кода пути, а ошибка сериализации останется в редком исключении. Поэтому проверка формы должна быть дополнена интеграционным тестом, который вызывает реальный handler, и тестом совместимости, который запускает старый клиент против нового ответа. Наличие двух тестов не делает контракт вечным: оно снижает конкретный риск в известной границе.'),
h2('Ограничения и следующий шаг'),
p('Учебный валидатор не проверяет OpenAPI-документ, авторизацию, базу данных, компрессию и сетевые ошибки. Он также не доказывает, что всех потребителей нашли. Его задача уже: не пропустить неверный тип, обязательное поле или новое значение enum на границе JSON.'),
p('Следующий шаг — собрать один реальный endpoint и добавить к нему пару contract-тестов: старый потребитель должен пройти на расширенном ответе, а breaking diff должен завершаться осознанным решением о версии. Если правило нельзя выразить в форме, status или условии запроса, вынесите его в отдельный раздел доменного контракта, не прячьте в описании поля.'),
], contractRefs);
const mechanism = revision({
slug: 'editorial-2027-09-mechanism-mentor-series',
title: 'JSON Schema и бизнес-правила: где проходит граница валидации',
categories: ['Backend', 'Контракты данных'],
cover: '/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg',
excerpt: 'Почему валидная JSON Schema не гарантирует корректную операцию: разделяем форму данных, бизнес-инвариант и проверку состояния.',
readingMinutes: 15,
}, [
p('Проблема возникает, когда сервис принимает хорошо сформированный JSON, но отклоняет операцию позже: лимит оказался недоступен, курс валюты устарел, а ресурс уже изменился. Цена смешения слоёв — неясная ошибка 400/409, повторные попытки клиента и спор о том, где именно нарушен контракт.'),
p('Причина обычно в широком слове «валидировать». Им называют проверку JSON-типа, обязательных полей, доступа пользователя и текущего состояния базы одновременно. Такой обработчик трудно тестировать: непонятно, какой вход должен быть отклонён схемой, а какой — доменной проверкой. Разделим эти решения и соберём минимальный фильтр, который можно запустить без сервера.'),
h2('Три слоя, которые нельзя склеивать'),
p('Первый слой — структура: объект, строка, число, массив, обязательность, формат и перечисление. JSON Schema хорошо подходит для такого вопроса. Второй слой — локальный инвариант: например, <code>minAmount &lt;= maxAmount</code> или допустимый размер страницы. Его можно проверять кодом после разбора JSON, если правило зависит от нескольких полей. Третий слой — состояние системы: существует ли пользователь, не занят ли ресурс, не истёк ли токен. Этот слой требует доступа к данным и обычно возвращает другой класс ошибки.'),
p('Если все три проверки спрятаны в одной схеме, описание начинает обещать больше, чем может проверить. Если всё оставить коду контроллера, клиенты теряют раннюю документацию и точное сообщение о форме. Рабочая граница проходит там, где появляется внешний контекст: схема описывает сам документ, доменная функция — связь полей, сервис — состояние и права.'),
table('Что проверять схемой, а что — кодом', ['Слой', 'Пример', 'Результат ошибки', 'Подход'], [
['Тип и обязательность', '<code>limit</code> — integer, required', '400: malformed document', 'JSON Schema или генератор клиента'],
['Диапазон', '<code>1 ≤ limit ≤ 100</code>', '400: invalid value', 'Schema minimum/maximum плюс тест'],
['Связь полей', '<code>from &lt;= to</code>', '400: inconsistent filter', 'Чистая функция с двумя полями'],
['Состояние', 'ресурс не изменён после чтения', '409: state conflict', 'Версия, условный запрос, транзакция'],
['Право', 'роль может менять статус', '403: forbidden', 'Авторизация до изменения состояния'],
]),
h2('Schema не делает неизвестное допустимым'),
p('У JSON Schema есть важное свойство: ограничения должны быть явными. Для API-фильтра можно разрешить <code>limit</code>, <code>cursor</code> и <code>state</code>, а остальные свойства закрыть через <code>additionalProperties: false</code> в нужном месте схемы. Но закрытость должна соответствовать расширяемости интерфейса. Если команда добавляет служебное поле без версионирования, строгая схема станет источником неожиданных отказов.'),
p('Есть и другая ловушка — использовать <code>format</code> как доказательство полной корректности. Формат даты или URI задаёт синтаксическую подсказку, но не подтверждает, что дата разрешена для операции или что URI принадлежит доверенному домену. Слово «valid» в отчёте должно иметь уточнение: valid по схеме, valid для инварианта или valid в текущем состоянии.'),
figure('/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg', 'Матрица валидации: структура JSON, связь полей, состояние ресурса и право на действие проходят отдельные проверки с разными классами ошибок.', 'Схема помогает не выдавать успешный разбор JSON за разрешение операции. Каждый слой имеет собственный вход, сообщение и границу ответственности.'),
h2('Runnable-пример: форма и инвариант по отдельности'),
p('В следующем фрагменте функция принимает фильтр поиска. Она проверяет форму и диапазон, добавляет безопасные значения по умолчанию и возвращает нормализованный объект. Это не библиотека JSON Schema, а маленький учебный аналог, на котором видно место бизнес-правила. Состояние базы и право доступа намеренно не притворяются частью результата.'),
code(`import { validateFilterInput } from './upgrade-2027-09.mjs';
const good = validateFilterInput({ state: 'active', limit: 25 });
const bad = validateFilterInput({ state: 'active', limit: 250 });
const unknown = validateFilterInput({ state: 'active', region: 'eu' });
console.log(good.ok, good.value.limit, good.value.cursor);
console.log(bad.ok, bad.reason);
console.log(unknown.ok, unknown.value.state);
// true 25 null
// false limit-out-of-range
// true active`),
h2('Порядок разложения проверки'),
ol([
'Опишите JSON-документ отдельно от команды, которая его использует. Назовите поля, типы, обязательность и допустимые значения.',
'Выберите закрытую или расширяемую модель неизвестных полей. Решение должно быть одинаковым для сервера и клиентов, иначе один слой будет отвергать данные другого.',
'Вынесите связи нескольких полей в чистые функции. Каждая функция должна иметь отрицательный пример и возвращать имя нарушенного правила.',
'Присвойте класс ошибки: malformed input, invalid value, conflict или forbidden. Не превращайте конфликт состояния в повторную отправку 400.',
'Проверьте, какие правила требуют чтения базы или другого сервиса. Для них зафиксируйте порядок проверки и условия гонки.',
'Сверьте документацию и код на одном fixture-наборе. Расхождение между схемой и runtime-валидатором должно ломать сборку тестов.',
]),
h2('Почему 409 важнее ещё одного boolean'),
p('Когда форма запроса корректна, но состояние изменилось, клиенту нужна возможность выбрать действие: перечитать ресурс, показать конфликт или прекратить операцию. Boolean вроде <code>valid: false</code> стирает причину. HTTP-семантика и локальный API-контракт должны различать ошибку документа и невозможность применить правильный документ к текущему состоянию.'),
p('Это различие помогает и с повторными попытками. Ошибка схемы не станет правильной от второго запроса, а конфликт иногда исчезает после нового чтения. Если оба случая имеют один статус, клиент либо повторяет бесполезную отправку, либо молча теряет возможность безопасного разрешения. Хорошая валидация уменьшает число retry-циклов именно тем, что сообщает границу отказа.'),
h2('Ограничения и следующий шаг'),
p('Учебная функция не реализует полный draft 2020–12, не строит JSON Pointer к ошибке и не читает доменное состояние. Она показывает архитектурное разделение, а не заменяет валидатор библиотеки. В реальном API необходимо проверить выбранную библиотеку на <code>oneOf</code>, ссылки, форматы и поведение при неизвестных ключах.'),
p('Следующий шаг — взять один endpoint с конфликтом состояния и выписать три независимых теста: неправильная форма, нарушенный инвариант и устаревшая версия ресурса. После этого сравните их статусы и сообщения с документацией. Если один тест требует данных, которых нет в запросе, не расширяйте схему вслепую: это сигнал, что правило относится к сервисному слою.'),
], contractRefs);
const field = revision({
slug: 'editorial-2027-09-field-mentor-series',
title: 'Code review API-изменения: от diff до обратимой миграции',
categories: ['Code review', 'Миграции'],
cover: '/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg',
excerpt: 'Полевой маршрут для API-diff: классифицируем несовместимость, проверяем потребителей и оставляем безопасное окно отката.',
readingMinutes: 15,
}, [
p('Проблема в code review API-изменения редко выглядит как красная строка. Автор добавляет обязательное поле, меняет enum или удаляет старый response-property, а reviewer видит только локально зелёные тесты. Цена ошибки появляется после публикации: разные версии клиента начинают спорить с одним сервером, а быстрый rollback уже не возвращает удалённое поле.'),
p('Причина — просматривать diff как изменение одного репозитория. API имеет потребителей, кэш, документацию, генераторы типов и иногда асинхронные события. Поэтому проверка должна начинаться с классификации изменения, продолжаться поиском потребителей и заканчиваться обратимой последовательностью. Ниже — практический маршрут, который можно применить к одному pull request.'),
h2('Сначала классификация, потом обсуждение кода'),
p('У каждой строки схемы есть направление совместимости. Добавление необязательного response-поля обычно расширяет контракт. Удаление поля сужает его. Добавление обязательного поля в request ломает старого отправителя, а изменение response-типа ломает десериализацию даже при том же имени. Эта классификация не заменяет review, но не даёт обсуждать все изменения одинаково.'),
p('Функция <code>classifyApiChange</code> ниже намеренно принимает уже выделенные факты diff. Она не пытается сама прочитать OpenAPI и не делает вывод о конкретной команде. Это удобная граница для теста: если генератор diff ошибся, его ошибка находится до классификатора; если классификатор выбрал <code>breaking</code>, reviewer получает повод проверить совместимость.'),
table('Минимальная карта API-diff', ['Вопрос', 'Признак', 'Что проверить', 'Решение'], [
['Старый клиент отправит запрос?', 'Новое required request-поле', 'Все builders и fixtures', 'Default, optional или новая версия'],
['Старый клиент прочитает ответ?', 'Удаление/переименование поля', 'Поиск доступа к property', 'Deprecated-период и новое поле'],
['Старое значение остаётся допустимым?', 'Сужение enum', 'Ветвления клиентов и событий', 'Расширить enum или сменить версию'],
['Сохранилась семантика?', 'Тот же тип, другое значение', 'Документация и consumer test', 'Явно описать смысл и миграцию'],
['Можно вернуть сервер?', 'Изменение хранения или записи', 'Backward read и rollback', 'Сначала expand, затем switch, потом contract'],
]),
h2('Обратимость начинается с данных'),
p('Откат бинарного файла не откатывает базу и сообщения в очереди. Если новый сервер записал только новый формат, старый сервер может не суметь прочитать данные. Поэтому для опасного API-изменения полезен expand/contract: сначала добавить совместимое поле или колонку, затем научить код читать и писать оба формата, переключить потребителей и только после подтверждения удалить старую форму.'),
p('На review стоит попросить не обещание «rollback возможен», а конкретную матрицу. Какие версии читают старую запись? Как выглядит запись после частичного переключения? Что произойдёт с повторной доставкой события? Где хранится сигнал, что старый consumer ещё жив? Ответы превращают риск в проверяемые условия, а не в уверенность по названию ветки.'),
figure('/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg', 'Маршрут review API-изменения: diff проходит через классификацию совместимости, проверку потребителей и окно обратимой миграции перед удалением старой формы.', 'Диаграмма связывает локальный diff с потребителями и данными. Красная ветка означает остановку до удаления, если старый формат ещё нужен.'),
h2('Runnable-пример: классифицируем diff'),
p('Вход функции — объект с тремя признаками: удалённые свойства, новые обязательные свойства и сужение enum. На выходе — <code>breaking</code> или <code>compatible</code> и действие для review. Это не автоматическое разрешение pull request. Пример полезен как первая страховка, после которой нужны реальные consumer tests и проверка данных.'),
code(`import { classifyApiChange } from './upgrade-2027-09.mjs';
const additive = classifyApiChange({
removedProperties: [],
addedRequiredProperties: [],
narrowedEnum: false,
});
const risky = classifyApiChange({
removedProperties: ['displayName'],
addedRequiredProperties: [],
narrowedEnum: false,
});
console.log(additive.status, additive.action);
console.log(risky.status, risky.action);
// compatible run-consumer-contract-tests
// breaking version-or-expand-compatibility-window`),
h2('Порядок review для одного diff'),
ol([
'Скопируйте в описание изменения старую и новую форму запроса, ответа и события. Diff схемы без примеров заставляет reviewer восстанавливать смысл по именам.',
'Запустите классификатор и вручную проверьте каждый breaking-признак: удаление, required, enum, тип и изменение семантики.',
'Найдите потребителей по сгенерированным типам, сериализаторам, документации и тестовым fixture. Отдельно проверьте неизвестные внешние клиенты.',
'Составьте матрицу чтения и записи старой и новой формы. Укажите, что произойдёт при частичном rollout и повторной доставке события.',
'Добавьте отрицательные contract-тесты для старого клиента и положительные для нового. Тест должен падать на конкретном поле, а не на общем статусе.',
'Опишите условие удаления старой формы: сигнал использования, срок хранения и способ восстановления. Без этого «временное поле» становится вечным.',
]),
h2('Ограничения автоматической классификации'),
p('Классификатор не знает, что <code>displayName</code> обязателен для внешнего клиента, а внутренний клиент его игнорирует. Он не проверяет кэш, подписанные payload, очереди и генерацию SDK. Даже правильный статус <code>breaking</code> не говорит, как долго держать две версии. Это инструмент сортировки риска, не замена архитектурному решению.'),
p('Не всякая совместимая форма безопасна семантически. Поле может остаться строкой, но начать содержать другой часовой пояс или другую единицу измерения. Поэтому в review нужен отдельный вопрос о значении, а не только о типе. Если смысл изменился, новое имя часто дешевле, чем заставлять клиентов угадывать период перехода.'),
h2('Ограничения и следующий шаг'),
p('Статья не описывает конкретный CI, брокер или схему базы. Примеры синтетические и запускаются локально; они показывают форму решений, а не результат изменения внешнего API. Для опасных контрактов потребуется интеграция с registry схем, consumer tests и наблюдаемым сигналом использования старого поля.'),
p('Следующий шаг — выбрать один настоящий diff и заполнить четыре артефакта: старая/новая схема, таблица потребителей, тест частичного rollout и процедура удаления. Если хотя бы один потребитель неизвестен, оставьте расширение совместимым и не переходите к contract-фазе миграции.'),
], contractRefs);
export const revisions = deepFreeze([practice, mechanism, field]);
export function runApiContractFixture() {
const cases = [
['response-accepts-known-state', validateCustomerResponse({ id: 'c-1', revision: 1, state: 'active' }).ok, true],
['response-rejects-unknown-state', validateCustomerResponse({ id: 'c-1', revision: 1, state: 'deleted' }).reason, 'state-is-outside-enum'],
['filter-applies-default', validateFilterInput({ state: 'blocked' }).value.limit, 20],
['filter-rejects-large-limit', validateFilterInput({ limit: 101 }).reason, 'limit-out-of-range'],
['diff-detects-breaking', classifyApiChange({ removedProperties: ['name'] }).status, 'breaking'],
['diff-keeps-additive-change', classifyApiChange({ removedProperties: [], addedRequiredProperties: [], narrowedEnum: false }).status, 'compatible'],
];
const checks = cases.map(([id, actual, expected]) => ({ id, actual, expected, passed: actual === expected }));
return deepFreeze({ passed: checks.filter((item) => item.passed).length, total: checks.length, accepted: checks.every((item) => item.passed), checks });
}
export function verifyRevisionsAgainstFixture() {
const fixture = runApiContractFixture();
const articleChecks = revisions.map((item) => {
const text = bodyText(item.contentHtml);
return text.length >= 5000 && text.length <= 15000 && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && /Проблема/.test(text.slice(0, 900));
});
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((item) => [item.slug, bodyText(item.contentHtml).length])) });
}
if (process.argv.includes('--verify-fixture')) {
const result = verifyRevisionsAgainstFixture();
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (!result.accepted) process.exitCode = 1;
}
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');