import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function paragraph(text) { return '
' + text + '
'; } function heading(text) { return '' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '';
}
function figure(src, alt, caption) {
return 'contractVersion, id, source, type', 'producer контракта', 'все обязательные поля непустые и type ожидаем consumer-ом'],
['Связь с предметом', 'subject', 'producer и владелец домена', 'subject указывает на объект, для которого имеет смысл разбор'],
['Payload', 'schemaVersion и data', 'producer payload', 'consumer явно принимает именно эту версию и stable fields'],
['Consumer', 'список versions и интерпретация полей', 'владелец конкретного consumer', 'нет молчаливого предположения о новом поле или значении'],
['Result', 'resultVersion и ключ consumerId:source:id', 'consumer и его хранилище результата', 'повтор того же source:id не создаёт второй учебный result без нового договора'],
],
),
paragraph('CloudEvents полезен здесь как дисциплина метаданных: исторический snapshot апреля 2021 года отдельно описывает context события, event data и protocol binding. Но нельзя взять несколько похожих имён и назвать любой object CloudEvent. У формата есть свои обязательные атрибуты и bindings. В этой статье contractVersion и schemaVersion принадлежат нашему учебному договору. Fixture не проверяет bindings и SDK.'),
heading('Пишем envelope так, чтобы его можно было проверить'),
paragraph('Поле id должно быть неизменным для одной логической записи. Оно не обязано одновременно быть ключом любого бизнес-эффекта: например, письмо может иметь отдельный idempotency key. Но без id нельзя доказать, что controlled replay относится к тому же входу. source отделяет producer от consumer и не должен строиться из случайного hostname. В учебном примере разрешён только закрытый prefix training://orders/; это делает ошибочный внешний source наблюдаемым до работы consumer.'),
codeBlock(envelopeCode),
paragraph('Время occurredAt не назначает порядок обработки. Это время, которое сообщил producer, а не позиция в topic и не время, когда consumer выполнил effect. Если домен требует порядок, нужен отдельный sequence contract и его owner. Если порядок не нужен, не стоит создавать ложную гарантию из timestamp. В этой партии это ограничение остаётся явным: fixture не сортирует delivery, не моделирует clock skew и не выбирает partition.'),
codeBlock(validationCode),
paragraph('Проверка envelope должна завершиться до любой интерпретации payload. Иначе consumer сначала создаст effect, а потом обнаружит, что source или type были неожиданными. В результате validateTrainingEnvelope() возвращает причину вроде missing-or-empty-type или source-outside-training-orders-boundary. Это не универсальный validator. Реальный сервис может потребовать подпись, tenant, permissions, content type и ограничения размера. Важен принцип: причина отказа должна быть в result, а не скрываться за общим exception.'),
figure(
'/assets/editorial/2021/event-topology-2021.svg',
'Вертикальная схема учебной топологии: producer формирует envelope и payload, delivery передаёт их consumer, consumer сначала валидирует contract, затем записывает versioned result в ledger; отдельная стрелка показывает duplicate или controlled replay с тем же source:id',
'Топология разделяет факт, доставку и результат. Broker на схеме — граница передачи, а не обещание конкретного продукта или гарантии.',
),
heading('SchemaVersion — не декоративное число'),
paragraph('Версия payload нужна не для красивого суффикса в названии события. Она говорит consumer, по каким правилам он может прочитать data. В учебном schemaVersion: 1 содержит orderId и status. Версия 2 добавляет paymentReference. Старый consumer v1 читает только стабильную пару и осознанно игнорирует новое поле. Новый consumer v2 умеет отдать paymentReference: null, когда воспроизводит старый v1 event. Это ограниченная, проверяемая политика, а не слово «backward compatible» без границы.'),
paragraph('Apache Avro разделяет writer schema и reader schema, а правила resolution зависят от конкретного формата и схем. Из этого полезно перенести не кодек, а вопрос: что именно writer записал и что reader имеет право ожидать. Наш JSON object не является Avro record. В нём нет writer schema, fingerprint, default из Avro и реального registry. Поэтому добавление поля здесь совместимо только потому, что два наших consumer contract явно так определены.'),
codeBlock(compatibilityCode),
dataTable(
'Матрица совместимости учебных contracts',
['Вход', 'Consumer contract', 'Разрешённый результат', 'Чего не обещает правило'],
[
['schema v1: orderId, status', 'orders-projection@1', 'stable projection из двух полей', 'что v1 понимает будущие значения status'],
['schema v2: v1 + paymentReference', 'orders-projection@1', 'то же stable projection, поле v2 намеренно игнорируется', 'что любой added field всегда безопасен'],
['schema v1 без нового поля', 'orders-projection@2', 'paymentReference: null в versioned result', 'что null равен неизвестному business state'],
['schema v2 с необязательным полем', 'orders-projection@2', 'projection с проверенным string или null', 'что значение ссылки корректно в соседней системе'],
['schema v3 с изменённым смыслом state', 'любой contract этой fixture', 'contract-update-required', 'что consumer может угадать семантику'],
],
),
heading('Consumer contract виден до replay'),
paragraph('У consumer должна быть короткая объявленная граница: accepted schema versions, набор читаемых полей, результат и ключ, по которому он видит повтор. Иначе внедрение становится опасной схемой «сначала обновим producer, потом посмотрим на ошибки». В примере orders-projection@1 и orders-projection@2 не обозначают версии broker. Это два независимых договора чтения одного type. Их результат хранит resultVersion, чтобы replay можно было связать с интерпретацией, которая действовала в момент обработки.'),
paragraph('Не надо автоматически принимать каждую большую версию, если JSON проходит синтаксис. Версия 3 в fixture меняет привычный status на state. Мы не считаем settled синонимом paid без решения владельца домена. Consumer возвращает contract-update-required и не пишет effect. Такой отказ дешевле тихой подмены смысла: в логе остаётся event id, input schemaVersion, consumer id и причина, по которым можно подготовить миграцию или отдельный адаптер.'),
codeBlock(unsupportedCode),
heading('Маршрут перед подключением реального broker'),
orderedList([
'Выбрать один event type и назвать symptom: какой consumer сейчас не может безопасно понять или повторить вход.',
'Отделить envelope, payload и consumer result. Для каждого записать владельца и только необходимые поля.',
'Определить стабильные поля, которые v1 consumer действительно читает, и одно добавочное поле следующей версии. Не менять семантику под именем «добавили поле».',
'Написать validator envelope до кода effect: id, source, type, subject, contractVersion, schemaVersion и обязательные stable fields.',
'Зафиксировать consumer contract: accepted versions, projection, resultVersion и исход для неизвестной версии.',
'Проверить controlled fixture с v1, v2, duplicate и replay. Убедиться, что replay сохраняет source:id, а не создаёт новый вход для обхода ledger.',
'Только после этого выбрать конкретный broker, serializer, storage result и integration test. Отдельно записать их реальные guarantees и failure modes.',
]),
heading('Что остаётся за границей этого шага'),
paragraph('У этой модели нет настоящего topic, consumer group, offset, transaction, outbox, schema registry, authorisation, encryption, retries сети, retention или delivery SLA. Нет и real production event: значения order-104 и training-pay-77 специально вымышлены. Kafka 2.7 documentation показывает, почему retry нельзя автоматически отождествлять с единственной доставкой, но этот факт не превращает Map в Kafka client. Аналогично CloudEvents не даёт бизнес-совместимость просто наличием envelope.'),
paragraph('Практический следующий шаг — взять один безобидный event type проекта и сделать такой же evidence packet: serialized envelope, declared payload schema, consumer version, sample result и controlled replay. Если хотя бы одно поле нельзя объяснить владельцем, не публикуйте его в общий contract. Сначала сузьте событие до проверяемого факта. Тогда новый consumer будет явным договором, а не догадкой по JSON.'),
],
[cloudEvents202104, avro101, kafkaProducer27],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2021-06-mechanism-event-driven',
title: 'Эволюция схемы события: где проходит граница совместимого consumer',
categories: ['Архитектура', 'События', 'Данные'],
cover: '/assets/editorial/2021/event-schema-compatibility-2021.svg',
excerpt: 'Новое поле не равно совместимой схемe. Разбираем writer, reader, stable fields, semantic change, versioned consumer result и путь, при котором неизвестная версия останавливает effect вместо тихой подмены данных.',
readingMinutes: 16,
},
[
paragraph('Симптом обычно выглядит безобидно: producer добавил поле paymentReference, JSON по-прежнему валиден, а старый consumer либо падает на строгой проверке, либо начинает использовать значение не по договору. Хуже другой случай: поле переименовали или поменяли его смысл, consumer продолжил работать и записал правдоподобный, но неверный результат. Цена тихой совместимости выше явного отказа: затем невозможно восстановить, какая версия входа породила конкретную запись.'),
paragraph('Здесь важно развести две вещи. Формат может суметь распарсить bytes, а consumer может не иметь права интерпретировать бизнес-смысл. В учебной модели v2 добавляет одно необязательное поле к устойчивой паре orderId и status. Consumer v1 объявляет, что читает только устойчивую пару. Consumer v2 умеет сохранить новое поле, но при replay v1 нормализует его к null. Модель не использует Avro, Kafka, CloudEvents или schema registry. ' + trainingBoundary),
heading('Совместимость начинается с пары writer и reader'),
paragraph('Фраза «схема совместима» бесполезна без двух участников. Нужно назвать writer schema, reader contract и направление проверки. Новый writer v2 может быть совместим со старым reader v1, если reader действительно игнорирует добавленное поле и его смысл не меняет старые поля. Старый writer v1 может быть совместим с новым reader v2, если новый reader знает, как честно обработать отсутствие нового поля. Но изменение status на state не становится совместимым только потому, что оба значения строки. Это уже смена интерпретации.'),
paragraph('Apache Avro формулирует это через writer и reader schema resolution. Конкретные правила зависят от record, default, alias и serialization. Для автора прикладного contract полезна более простая привычка: на каждый change показать одну старую запись, один новый consumer и один новый event для старого consumer. Если эти два направления не проверены, словом compatible называют только надежду. В нашей fixture оба направления являются отдельными assertions.'),
dataTable(
'Направления совместимости: какую пару проверяем',
['Writer event', 'Reader contract', 'Учебный verdict', 'Причина'],
[
['v1: orderId, status', 'v1', 'готово', 'оба используют один набор stable fields'],
['v2: v1 + optional paymentReference', 'v1', 'готово с игнорированием поля', 'v1 contract читает только описанную пару'],
['v1 без paymentReference', 'v2', 'готово с null', 'v2 contract явно определяет default представления'],
['v2 с пустой или неверной ссылкой', 'v2', 'rejected envelope', 'валидность поля проверяется до projection'],
['v3: state вместо status', 'v1 или v2', 'contract update required', 'смысл stable field больше не доказан'],
],
),
heading('Стабильное поле имеет не только имя'),
paragraph('Стабильность — это имя, тип и договорённость о смысле. status: "paid" нельзя заменить на state: "settled" и сказать старому consumer, что он должен «как-нибудь понять». Даже если домен считает значения близкими, у consumer могут быть ветки, аудит, SQL-проекция или внешняя команда, которые используют старое значение как ключ. Поэтому schemaVersion должна расти при таком change, а consumer должен либо получить отдельный адаптер с тестом, либо остановить effect до решения.'),
paragraph('Добавочное поле тоже не автоматически безопасно. Оно безопасно для конкретного reader, когда reader не использует unknown fields для валидации и новое поле не меняет meaning предыдущих. Например, paymentReference в нашем v2 — optional string, который v1 не читает. Но если producer вводит currency и одновременно начинает иначе понимать amount, это не «добавили currency». Это изменение смысла суммы, требующее отдельного type или миграции. Контракт удобнее держать маленьким, чем потом спасать широкую схему исключениями.'),
codeBlock(compatibilityCode),
figure(
'/assets/editorial/2021/event-schema-compatibility-2021.svg',
'Вертикальная схема матрицы совместимости: schema v1 содержит orderId и status, v2 добавляет optional paymentReference; consumer v1 игнорирует добавление, consumer v2 нормализует старый event к null, schema v3 направляется в contract update вместо автоматического effect',
'Схема показывает направления reader и writer, а не обещает, что одна версия формата подходит всем consumer.',
),
heading('Versioned result связывает replay с интерпретацией'),
paragraph('Event schemaVersion недостаточно, когда один и тот же event воспроизводят разные consumer. В fixture результат содержит inputSchemaVersion и resultVersion. Первый отвечает на вопрос, с каким payload пришёл вход. Второй отвечает, какой consumer contract создал projection. Это особенно важно при исправлении consumer: нельзя сказать, что replay «пересчитал данные», если не видно, старый или новый код дал результат.'),
paragraph('Здесь resultVersion не является номером deploy, Git commit или версией broker. Это стабильное имя интерпретации 2021-06.orders-projection.1 или 2021-06.orders-projection.2. В реальном проекте к нему могут добавиться build, schema fingerprint или migration id. Но не стоит приклеивать всё сразу: достаточно обеспечить один ответ на вопрос расследования — по какому договору consumer прочёл event и что он записал.'),
codeBlock(resultCode),
dataTable(
'Что хранить рядом с результатом consumer',
['Факт', 'Зачем нужен', 'Недостаточный заменитель'],
[
['event.id и source', 'связать result с исходным логическим входом', 'только время обработки'],
['inputSchemaVersion', 'понять форму data, которую видел consumer', 'название topic или queue'],
['consumerId', 'отделить два самостоятельных read contract', 'общее имя сервиса'],
['resultVersion', 'отделить старую и новую интерпретацию при replay', 'случайный build timestamp'],
['решение effect-recorded или отказ', 'не спутать обработанный input с безопасно интерпретированным input', 'одна строка «consumer finished»'],
],
),
heading('Unknown version должна менять маршрут, а не парсинг'),
paragraph('Некоторые команды делают consumer permissive: он принимает любое число schemaVersion и берёт знакомые поля, надеясь, что остальное неважно. Такой подход удобен до первого semantic change. В нашем примере v3 содержит state, а не status. Envelope по форме всё ещё даёт origin, id и type, но v1/v2 contracts не объявили это значение. Поэтому projectForConsumer() возвращает contract-update-required и запрещает effect.'),
paragraph('Это не означает, что каждое новое поле останавливает весь поток. Значит другое: разные категории изменений имеют разные правила. Новый optional field, который reader не читает, может пройти по заранее описанной ветке. Новая обязательная семантика, удаление stable field, смена единицы или переименование требуют explicit consumer change. Если такой change нельзя выполнить быстро, лучше сохранить event и manual evidence, чем превратить неизвестное значение в default без владельца.'),
codeBlock(unsupportedCode),
heading('Schema registry полезен, но не заменяет договор'),
paragraph('Schema registry может хранить definitions, compatibility modes и историю. Но сам факт регистрации не доказывает, что конкретный consumer хранит result idempotently, что event type соответствует доменному действию или что versioned replay безопасен. И наоборот, маленький проект может начать с versioned fixture и JSON-schema-like checks без registry, пока договор виден в коде и review. В обоих случаях остаются одни и те же вопросы: кто публикует schema, что принимает reader, где записан migration и как остановить неизвестный вход.'),
paragraph('CloudEvents в историческом snapshot апреля 2021 года описывает общую форму event metadata, а Kafka 2.7 documentation различает producer send и возможность duplicate при retry. Ни один источник не говорит, что добавление поля в любую JSON data автоматически совместимо со всеми business consumer. Вся совместимость в этой статье ограничена двумя contracts и одной функцией projection. Так и должно быть: общий стандарт помогает передать envelope, но не владеет семантикой заказа.'),
heading('Маршрут изменения схемы'),
orderedList([
'Записать одну старую запись и один будущий event в отдельном fixture. Не начинать с массового изменения producer.',
'Назвать stable fields, их тип и смысл. Если смысл меняется, считать это новым contract, даже когда JSON key похож.',
'Проверить новый writer со старым reader: какие поля reader читает, какие игнорирует и почему это безопасно.',
'Проверить старый writer с новым reader: какой explicit default или отдельный route получает отсутствующее поле.',
'Добавить inputSchemaVersion и resultVersion к result consumer, чтобы replay был объясним.',
'Для unknown schemaVersion вернуть contract update required без effect. Не пытаться перевести незнакомые данные по имени поля.',
'После fixture выбрать реальный serialization format, registry policy и integration test; их правила записать отдельно от учебной модели.',
]),
heading('Граница знания и следующий тест'),
paragraph('Fixture не читает Avro bytes, не проверяет JSON Schema, не общается с registry и не запускает Kubernetes, broker или database. Она не доказывает backwards compatibility продукта и не измеряет lag consumer. Она проверяет только конкретный контракт: v2 добавляет поле, v1 его не читает, v2 consumer умеет представить absence как null, v3 не вызывает effect. Если ваш consumer использует enum, money, locale или permission, это должны быть отдельные assertions, а не перенос нашей пары полей.'),
paragraph('Следующий практичный шаг — добавить к изменению схемы review-таблицу из этой статьи и один replay test на выбранном хранилище результата. В хорошем результате будет видно event id, writer schema, consumer contract и исход effect. Если такой след не получается собрать без догадок, schema evolution пока рано выпускать: сначала надо сделать наблюдаемой границу reader и writer.'),
],
[avro101, cloudEvents202104, kafkaProducer27],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2021-06-field-event-driven',
title: 'Replay события: как не записать второй result и не скрыть новую схему',
categories: ['Архитектура', 'События', 'Отладка'],
cover: '/assets/editorial/2021/event-replay-diagnosis-2021.svg',
excerpt: 'Replay полезен только тогда, когда видно исходный event, consumer contract и уже записанный result. Разбираем evidence packet, duplicate, controlled replay, schema mismatch и маршрут без обещания exactly-once.',
readingMinutes: 16,
},
[
paragraph('Симптом после восстановления consumer звучит просто: «нужно проиграть события ещё раз». Затем один event приходит повторно, второй consumer уже обновлён, а в storage появляется непонятный result. Если replay создаёт новый id, невозможно отличить повтор старого факта от нового факта. Если он сохраняет id, но consumer не ведёт ledger, можно записать второй effect. Цена — не только дубль. Команда теряет доказательство, по какому contract был получен каждый результат, и не может безопасно решить, что делать со старой схемой.'),
paragraph('Полезная точка старта — не ручной запуск всех consumer, а evidence packet из пяти частей: envelope id и source, event type, input schemaVersion, consumer id/resultVersion, решение по ledger. В этой статье replay обозначает повторную delivery того же учебного source:id. Он не открывает реальный topic, не перемещает offset и не повторяет Kafka record. ' + trainingBoundary + ' Поэтому result duplicate-or-replay-suppressed означает только, что Map уже видела тот же consumer contract и source:id.'),
heading('Replay сохраняет логическую идентичность'),
paragraph('Самая опасная «починка» — сделать новое event id, чтобы consumer не счёл запись duplicate. Так обходят проверку, но меняют вопрос. Новый id может означать новый факт, исправленную команду или технический replay; эти случаи нельзя сливать. Для controlled replay неизменным остаётся исходный id, source, type, subject и payload schema. Дополнительная причина replay может жить рядом с операционной записью, но не должна подменять исходный event. Тогда ledger способен ответить: этот contract уже обработал данный вход или нет.'),
paragraph('Ключ ledger в fixture — consumerId:source:event.id. Source входит в identity: один и тот же id из другого producer не должен случайно подавить отдельный факт. Он подходит только для демонстрации одного projection result. В реальном домене этого может быть мало: внешний effect иногда нужно ключевать по business intent, а два разных consumer могут законно создать разные projections по одному event. Не надо переносить ключ как готовую идемпотентность. Сначала надо назвать effect и его owner, затем проверить, где хранится receipt вместе с результатом.'),
codeBlock(deliveryCode),
dataTable(
'Одна запись, три delivery: ожидаемый учебный результат',
['Delivery kind', 'Event identity', 'Ledger до шага', 'Решение', 'Effect'],
[
['initial-delivery', 'тот же source:id', 'нет ключа consumer', 'effect-recorded', 'один учебный result записан'],
['duplicate-delivery', 'тот же source:id', 'ключ уже есть', 'duplicate-or-replay-suppressed', 'новый result не пишется'],
['controlled-replay', 'тот же source:id', 'ключ уже есть', 'duplicate-or-replay-suppressed', 'replay не обходил ledger'],
['historical-replay в другом contract', 'тот же source:id', 'другой ledger consumer', 'effect-recorded с новой resultVersion', 'разный reader result допустим'],
['delivery schema v3', 'новый event id, неизвестная schema', 'неважно', 'contract-update-required', 'effect запрещён'],
],
),
heading('Duplicate и новая интерпретация — разные развилки'),
paragraph('Duplicate по тому же contract не должен создавать второй result. Но новое правило consumer иногда действительно требует пересчитать projection. Тогда не стоит удалять старую ledger запись и притворяться, что история не существовала. В fixture orders-projection@2 имеет другой ledger key и resultVersion; historical replay v1 может создать свой result, потому что это другой declared reader contract. Такой выбор не делает результат «истиннее», он делает видимой новую интерпретацию.'),
paragraph('Перед этим шагом нужно определить, разрешён ли второй projection в домене. Для email, платёжа или изменения внешней системы одного consumerId:source:event.id почти наверняка недостаточно: потребуется effect key на внешней границе, транзакция или ручное решение. Для локальной read projection иногда допустимо хранить две версии и переключать reader после сверки. Статья не выбирает между этими архитектурами. Она требует, чтобы автор replay написал, какой effect будет создан и почему второй contract имеет право его создавать.'),
codeBlock(resultCode),
figure(
'/assets/editorial/2021/event-replay-diagnosis-2021.svg',
'Вертикальное дерево диагностики replay: сначала проверяются source, id, type и schemaVersion, затем consumer contract и ledger; ветки ведут к первому result, suppress duplicate, отдельному versioned replay или contract update required для неизвестной схемы',
'Диагностика не считает replay ошибкой сама по себе. Она отделяет повтор того же result от новой явно объявленной интерпретации.',
),
heading('Схема не должна исчезать за duplicate'),
paragraph('Проверка ledger не заменяет проверку schema. Если v3 event пришёл с незнакомым state, consumer не должен сначала посмотреть id, не найти его и записать effect только потому, что это первый delivery. В учебном алгоритме порядок другой: validate envelope, выбрать consumer contract, проверить accepted schema version, затем читать ledger. Это сохраняет важный факт: новый event был получен, но effect запрещён из-за договора, а не потерян как «неизвестная ошибка».'),
paragraph('Также нельзя делать обратное: любой duplicate автоматически игнорировать до записи diagnostics. Result suppress содержит deliveryKind, ledgerKey, input schema и result version первого результата. Это не production audit trail, но минимальное evidence помогает отличить повтор сети от operator replay. Если реальная система не сохраняет эти факты, расследование начнётся с догадки: очередной запуск создал запись или просто повторил уже завершённую delivery.'),
codeBlock(unsupportedCode),
heading('Fixture задаёт узкую, но полезную проверку'),
paragraph('Фикстура использует два event v1/v2, один намеренно несовместимый v3, два consumer contract и две Map. Она проверяет envelope boundary, foreign source, additive v2 для v1 reader, normalisation old event в v2 reader, один записанный result, suppress duplicate, suppress controlled replay, отдельный resultVersion второго consumer и остановку v3. Assertions не измеряют время, не моделируют crash между внешним effect и receipt и не говорят ничего о exactly-once. Их задача скромнее: не дать редактуре незаметно поменять «тот же id подавляется» на «replay всегда пишет ещё раз».'),
codeBlock(fixtureCode),
paragraph('Apache Kafka 2.7 producer API прямо рассматривает retries и возможность duplicate в некоторых режимах. Это хороший повод не обещать exactly-once одним словом. Конкретные guarantees зависят от producer, broker, consumer, storage и внешней границы. CloudEvents в историческом snapshot апреля 2021 года помогает разделить context и event data, но не задаёт business receipt. В статье оба источника ограничивают формулировку, а не дают готовую implementation.'),
dataTable(
'Диагноз перед replay: факт, проверка, действие',
['Наблюдение', 'Что собрать', 'Безопасное действие', 'Чего не делать'],
[
['тот же source:id, same consumer contract', 'ledger key и первый resultVersion', 'suppress duplicate, сохранить evidence delivery', 'создавать новый id ради обхода проверки'],
['тот же event, новый declared consumer contract', 'старый и новый resultVersion, тип effect', 'отдельный controlled replay после явного решения', 'удалять старую receipt и терять историю'],
['schemaVersion не объявлена consumer', 'envelope, input schema, причина отказа', 'contract update или manual route без effect', 'угадывать поле по похожему имени'],
['source или type неожиданны', 'validation reason до payload', 'rejected envelope и проверка producer boundary', 'выполнять partial effect для «похожего» входа'],
['внешний effect уже мог быть сделан', 'idempotency contract внешней системы и receipt', 'остановить автоматический replay до доказательства', 'считать Map заменой транзакции'],
],
),
heading('Маршрут controlled replay'),
orderedList([
'Зафиксировать исходный event id, source, type, subject, payload schema и причину replay. Не заменять их новым JSON.',
'Проверить envelope и consumer contract до ledger. Unknown type или schema должен дать объяснимый отказ без effect.',
'Выбрать key, который соответствует именно данному consumer result; отдельно обсудить key доменного или внешнего effect.',
'Если ledger уже содержит same consumer contract + source:id, suppress replay и сохранить delivery evidence.',
'Если нужен новый расчёт, ввести новый declared consumer contract/resultVersion. Не перезаписывать старую интерпретацию без следа.',
'Для historical event проверить old writer с new reader: default, null или manual route должны быть записаны явно.',
'Перед реальным запуском выполнить integration test выбранного broker, storage и external API. Проверить их actual retries, crash window и rollback отдельно.',
]),
heading('Граница этого разбора'),
paragraph('Здесь нет реального broker, schema registry, consumer offset, listener, HTTP, database, external payment, audit store, browser, CI или deployment. Нет данных пользователей, measured throughput, lag или историй production incident. Также нет claim, что consumerId:source:event.id гарантирует exactly-once. Он даёт один воспроизводимый answer внутри Map: текущий учебный consumer уже записал result для этого source:id или ещё нет.'),
paragraph('После чтения стоит выбрать один невысокорисковый projection, а не внешний effect, и пройти тот же маршрут на интеграционном стенде. Хороший тест покажет initial delivery, duplicate, controlled replay и unknown schema с реальными версиями выбранных компонентов. Если для внешнего effect нет подтверждённого idempotency contract, автоматический replay не следует включать. В таком случае честный результат — сохранить evidence и передать решение владельцу операции.'),
],
[kafkaProducer27, cloudEvents202104, avro101],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
const isMainModule = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else if (process.argv.includes('--verify-fixture')) {
const fixture = runEventIntegrationFixture();
if (!Object.values(fixture.assertions).every(Boolean)) {
throw new Error('event integration fixture assertions failed');
}
process.stdout.write(JSON.stringify(fixture, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2021-06.mjs --print-revisions | --verify-fixture\n');
}
}