revise August 2021 storage contract articles
Build and deploy / deploy (push) Successful in 16s

This commit is contained in:
2026-07-31 13:02:44 +03:00
parent 2a30c8816c
commit 35769c4149
7 changed files with 953 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 127 из 358 созданных материалов. Остальные 231 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 130 из 358 созданных материалов. Остальные 228 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
+158
View File
@@ -0,0 +1,158 @@
# Автономное тройное ревью П42 · август 2021 · «Контракт хранилища»
Статус: **принята к публикации после независимой интеграции**. Изначально
registry и Git автономным пакетом не менялись.
Пакет заменяет через overlay только три стабильных slug:
- <code>editorial-2021-08-practice-storage-contracts</code>;
- <code>editorial-2021-08-mechanism-storage-contracts</code>;
- <code>editorial-2021-08-field-storage-contracts</code>.
Созданы ровно пять файлов П42:
- <code>web/scripts/upgrade-2021-08.mjs</code>;
- <code>web/public/assets/editorial/2021/storage-contract-compatibility-2021.svg</code>;
- <code>web/public/assets/editorial/2021/storage-contract-evolution-2021.svg</code>;
- <code>web/public/assets/editorial/2021/storage-contract-diagnosis-2021.svg</code>;
- этот документ.
Revision-модуль экспортирует только изменяемые редакционные поля. В нём нет
<code>date</code>, <code>author</code>, registry import или изменения
<code>articles.json</code>. Fixture использует только Array, Map и objects в
одном Node-процессе; не запускает БД, broker, реальные файлы, сеть, provider,
миграционный job или сервис.
## Проход 1. Структура, тон и объём — пройдено
| Revision | Ситуация и цена в первых двух абзацах | M4 и практическая граница | Объём body |
| --- | --- | --- | --- |
| Практика | Новый <code>timezone</code> смешивается с absent/null; цена — запись, которую старый reader трактует иначе | owner, поддержка старых pair и reader-before-writer rollout | **10 574** знака body |
| Механизм | Reader принял форму, но не смог объяснить значение; цена — закреплённая догадка вместо договора | type, presence, narrowing и semantic break разделены | **11 259** знаков body |
| Полевой разбор | Reader упал после записи; цена — потеря evidence при поспешном rollback | evidence, stop criterion и rollback-safe действия | **10 349** знаков body |
- У всех трёх текстов есть ситуация и стоимость ошибки в первых двух абзацах,
таблица, привязанный SVG с содержательными <code>alt</code>/<code>figcaption</code>,
рабочие JS-фрагменты, нумерованный маршрут, ограничения и отдельный раздел
источников.
- Голос М4 августа 2021 года: короткая инженерная речь «симптом → причина →
проверка → действие», явный owner и граница между reader, writer и contract.
Текст не изображает автора владельцем platform/SLA или реальной migration
program.
- Удалены универсальные формулы о «современном подходе»: каждое обобщение
привязано к profile.settings, matrix или fixture.
Вердикт: **пройден**. <code>audit:draft</code> подтвердил диапазон 5 000–15 000
знаков и наличие sections, table, figure, code, route, sources и локального
asset у каждого revision.
## Проход 2. Техника, source boundaries и fixture — пройдено
| Граница | Что утверждает пакет | Как это проверяется | Чего пакет не утверждает |
| --- | --- | --- | --- |
| JSON object | reader читает именованные key, а не их позицию | reordered v2 record даёт тот же нормализованный result | одинаковый порядок во всех parser или storage |
| Optional field | absent, <code>null</code> и строка — разные states | v1 → v2 даёт absent; v2 clear даёт explicit-null | absent всегда должен означать clear |
| Additive change | v2 добавляет only optional <code>timezone</code> | v2 writer → v1 reader и v1 writer → v2 reader зелёные в matrix | любой новый key безопасен для любого consumer |
| Breaking changes | narrowing, semantic change и required timezone отвергаются | три отдельные negative matrix assertions | generic JSON сам даёт эволюционную совместимость |
Первичные и официальные источники ограничивают историческую рамку:
- [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259.html) для JSON object,
literal <code>null</code> и отсутствия переносимого договора о порядке
object members;
- [JSON Schema draft 2019-09 Validation](https://json-schema.org/draft/2019-09/draft-handrews-json-schema-validation-02), официальный опубликованный Draft от 17 сентября 2019 года (не IETF RFC),
для vocabulary structural assertions и типа <code>null</code>;
- [Apache Avro 1.10.2 Specification](https://avro.apache.org/docs/1.10.2/spec.pdf)
для ограниченного, форматно-зависимого примера writer/reader schema resolution.
<code>runStorageContractFixture()</code> создаёт v1, additive v2, v2 clear,
legacy <code>daily</code>, type error и семь направлений matrix. Она требует
тринадцать истинных assertions: v1/v2 reads, additive direction, absent versus
explicit null, order independence, required-field rejection, type rejection,
narrowing, semantic and presence break. Функция не вызывает файловую систему,
сеть, БД, broker или внешний API.
Вердикт: **пройден**. Технические claims ограничены fixture и указанными
документами; format-specific Avro rules не переносятся на generic JSON.
## Проход 3. Фактическая точность, SVG и preflight — пройдено
- Во всех SVG есть <code>title</code>, <code>desc</code> и
<code>role="img"</code>. В схемах показаны только contract-level шаги:
compatibility directions, reader-before-writer evolution и diagnostic tree.
Статическая safety-проверка не нашла <code>script</code>,
<code>foreignObject</code>, внешние asset URL или raster data URI. Тексты,
стрелки и карточки рассчитаны на статичный просмотр, а не заменяют browser
или screen-reader проверку.
- Первый Sharp-рендер обнаружил два настоящих дефекта: длинная подпись
выходила за правую границу в evolution SVG, а четыре узкие ветви diagnosis
SVG плохо читались на 375 px. Заголовок и нижняя подпись evolution сокращены;
diagnosis перестроен в четыре широкие карточки. После правки повторный
375 px рендер просмотрен вручную: clipping, overlap и horizontal overflow
внутри трёх схем не обнаружены.
### Команды preflight
<pre><code>cd web &amp;&amp; node --check scripts/upgrade-2021-08.mjs
cd web &amp;&amp; npm run audit:draft -- scripts/upgrade-2021-08.mjs
cd web &amp;&amp; node scripts/upgrade-2021-08.mjs --verify-fixture
cd web &amp;&amp; xmllint --noout \
public/assets/editorial/2021/storage-contract-compatibility-2021.svg \
public/assets/editorial/2021/storage-contract-evolution-2021.svg \
public/assets/editorial/2021/storage-contract-diagnosis-2021.svg</code></pre>
### Финальные фактические результаты
Проверки выполнены 31 июля 2026 года после всех правок:
| Проверка | Реальный результат |
| --- | --- |
| <code>node --check</code> | PASS, code 0 |
| <code>npm run audit:draft</code> | PASS: **10 574 / 11 259 / 10 349** знаков body; у всех трёх slug есть table, figure, code, route, sources и локальный SVG |
| In-memory fixture | PASS: **13/13** assertions истинны; зелёные v1/v2 направления и три отрицательные matrix-пары проверены |
| <code>xmllint --noout</code> | PASS, три SVG — корректный XML |
| SVG safety scan | PASS: не найдены active tags, <code>foreignObject</code>, внешние assets или raster data URI |
| Sharp mobile preflight | PASS: три финальных PNG шириной 375 px вручную просмотрены после правки; нет clipping, overlap или horizontal overflow |
| Scope/self-review | PASS: П42 создала только пять разрешённых файлов; registry, README, <code>articles.json</code>, audit scripts, общие правила/очередь и Git не менялись; mascot PNG не затрагивались |
<code>npm run audit:draft</code> вывела старые предупреждения пользовательской
конфигурации npm о <code>store-dir</code>, <code>cache-dir</code> и
<code>public-hoist-pattern</code>. Они не относятся к П42 и не изменялись.
Integration registry audit, production build, browser, реальное storage,
migration, CI, commit и push намеренно не запускались: они находятся за
границей автономной П42.
Выпусковой вердикт автономной партии: **готова к независимой интеграции**.
## Независимая редактура и интеграция · 31 июля 2026
Основной редактор провёл ещё один технический и исторический проход.
1. Assertion `genericJsonDoesNotBypassTheContract` дублировал проверку
`timezone: 3`. Его заменили на отдельный случай: синтаксически корректное
JSON-значение <code>emailDigest: "hourly"</code> не входит в договор и
отклоняется reader v1. Так fixture различает форму JSON и доменное
перечисление, не увеличивая формальное число assertions.
2. Источник JSON Schema уточнён как официальный опубликованный Draft 2019-09
от 17 сентября 2019 года, а не IETF RFC. Avro 1.10.2 дополнительно
сверена по официальному анонсу выпуска от 15 марта 2021 года. Оба источника
существовали к августу 2021-го; правила Avro по-прежнему не переносятся на
generic JSON.
3. Все три SVG повторно просмотрены после независимого Sharp-рендера на 375 px:
читабельны направления совместимости, порядок rollout и четыре ветви
диагностики; clipping, overlap и overflow не обнаружены.
| Проверка | Результат независимого прохода |
| --- | --- |
| `node --check` и draft gate | PASS: **10 574 / 11 259 / 10 349** знаков body |
| Fixture | PASS: 13/13; есть отдельные проверки absent/null, type, допустимого формата с недопустимым enum, narrowing, semantic и presence break |
| `xmllint` и SVG safety scan | PASS: три XML-диаграммы корректны; не найдены script, foreignObject, внешние URL или raster data URI |
| Strict audit через registry | PASS: 1 figure, 2 table и 3–4 code examples в каждой revision; объёмы **10 574 / 11 259 / 10 349** |
| Production build | PASS: Next.js сгенерировал 374 статические страницы |
Три revision подключены к `web/data/editorial-revisions.mjs`; архив остаётся
владельцем stable slug, даты и автора. После интеграции строгий охват —
**130 из 358**, осталось **228** материалов.
Финальный вердикт: **ACCEPTED FOR PUBLICATION**.
+2
View File
@@ -38,6 +38,7 @@ import { revisions as april2021Revisions } from '../scripts/upgrade-2021-04.mjs'
import { revisions as may2021Revisions } from '../scripts/upgrade-2021-05.mjs';
import { revisions as june2021Revisions } from '../scripts/upgrade-2021-06.mjs';
import { revisions as july2021Revisions } from '../scripts/upgrade-2021-07.mjs';
import { revisions as august2021Revisions } from '../scripts/upgrade-2021-08.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -81,4 +82,5 @@ export const editorialRevisions = [
...may2021Revisions,
...june2021Revisions,
...july2021Revisions,
...august2021Revisions,
];
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1280" viewBox="0 0 1200 1280" role="img" aria-labelledby="title desc">
<title id="title">Совместимость writer и reader для контракта profile.settings</title>
<desc id="desc">Writer v1 и v2 записывают настройки профиля. Writer v2 добавляет необязательный timezone. Reader v1 игнорирует неизвестный optional ключ, reader v2 различает absent, null и строковое значение. Внизу показаны запрещённые сужение значений и смена смысла.</desc>
<rect width="1200" height="1280" fill="#0b1220"/>
<rect x="48" y="42" width="1104" height="108" rx="24" fill="#13233e" stroke="#5dd6ff" stroke-width="3"/>
<text x="92" y="94" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#f8fbff">Контракт profile.settings: совместимость</text>
<text x="92" y="127" font-family="Arial, sans-serif" font-size="25" fill="#b7c9df">Проверяем конкретную пару writer → reader</text>
<rect x="70" y="220" width="420" height="226" rx="22" fill="#1a304d" stroke="#72b9ff" stroke-width="3"/>
<text x="104" y="274" font-family="Arial, sans-serif" font-size="35" font-weight="700" fill="#ffffff">writer v1</text>
<text x="104" y="319" font-family="Arial, sans-serif" font-size="27" fill="#dcecff">id · name · settings</text>
<text x="104" y="360" font-family="Arial, sans-serif" font-size="25" fill="#9ec6eb">timezone: absent</text>
<text x="104" y="405" font-family="Arial, sans-serif" font-size="23" fill="#f9d889">digest: off / weekly / daily</text>
<rect x="710" y="220" width="420" height="226" rx="22" fill="#173c39" stroke="#64e2b8" stroke-width="3"/>
<text x="744" y="274" font-family="Arial, sans-serif" font-size="35" font-weight="700" fill="#ffffff">writer v2</text>
<text x="744" y="319" font-family="Arial, sans-serif" font-size="27" fill="#d7fff0">тот же core</text>
<text x="744" y="360" font-family="Arial, sans-serif" font-size="25" fill="#a4e4d0">+ optional timezone</text>
<text x="744" y="405" font-family="Arial, sans-serif" font-size="23" fill="#f9d889">absent / null / string</text>
<path d="M490 333 H690" stroke="#7ee5c5" stroke-width="8" fill="none" marker-end="url(#arrowGreen)"/>
<text x="510" y="304" font-family="Arial, sans-serif" font-size="23" fill="#b9f3df">additive key</text>
<rect x="70" y="560" width="420" height="244" rx="22" fill="#1b2c47" stroke="#84aefa" stroke-width="3"/>
<text x="104" y="616" font-family="Arial, sans-serif" font-size="35" font-weight="700" fill="#ffffff">reader v1</text>
<text x="104" y="662" font-family="Arial, sans-serif" font-size="26" fill="#dcecff">читает именованный core</text>
<text x="104" y="703" font-family="Arial, sans-serif" font-size="25" fill="#b4c9e8">unknown optional: ignore</text>
<text x="104" y="751" font-family="Arial, sans-serif" font-size="23" fill="#f9d889">timezone не required</text>
<rect x="710" y="560" width="420" height="244" rx="22" fill="#16433b" stroke="#70e6be" stroke-width="3"/>
<text x="744" y="616" font-family="Arial, sans-serif" font-size="35" font-weight="700" fill="#ffffff">reader v2</text>
<text x="744" y="662" font-family="Arial, sans-serif" font-size="26" fill="#d7fff0">читает core + timezone</text>
<text x="744" y="703" font-family="Arial, sans-serif" font-size="25" fill="#a4e4d0">absent ≠ null ≠ string</text>
<text x="744" y="751" font-family="Arial, sans-serif" font-size="23" fill="#f9d889">старый record читаем</text>
<path d="M280 446 V540" stroke="#75d9ff" stroke-width="8" fill="none" marker-end="url(#arrowBlue)"/>
<path d="M920 446 V540" stroke="#7ee5c5" stroke-width="8" fill="none" marker-end="url(#arrowGreen)"/>
<path d="M490 676 H690" stroke="#7ee5c5" stroke-width="8" fill="none" marker-end="url(#arrowGreen)"/>
<text x="530" y="646" font-family="Arial, sans-serif" font-size="22" fill="#b9f3df">доказанная пара</text>
<rect x="70" y="915" width="1060" height="280" rx="24" fill="#392432" stroke="#ff8fa4" stroke-width="3"/>
<text x="108" y="970" font-family="Arial, sans-serif" font-size="34" font-weight="700" fill="#fff5f7">Красная граница: тест обязан остановить изменение</text>
<circle cx="130" cy="1038" r="12" fill="#ff8fa4"/>
<text x="164" y="1048" font-family="Arial, sans-serif" font-size="27" fill="#ffe0e6">narrowing: новый reader отвергает legacy значение daily</text>
<circle cx="130" cy="1100" r="12" fill="#ff8fa4"/>
<text x="164" y="1110" font-family="Arial, sans-serif" font-size="27" fill="#ffe0e6">semantic change: weekly остаётся строкой, но меняет смысл</text>
<circle cx="130" cy="1162" r="12" fill="#ff8fa4"/>
<text x="164" y="1172" font-family="Arial, sans-serif" font-size="27" fill="#ffe0e6">presence break: старому writer нельзя внезапно потребовать timezone</text>
<defs>
<marker id="arrowGreen" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 z" fill="#7ee5c5"/></marker>
<marker id="arrowBlue" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 z" fill="#75d9ff"/></marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1280" viewBox="0 0 1200 1280" role="img" aria-labelledby="title desc">
<title id="title">Диагностика падения reader после записи</title>
<desc id="desc">Дерево диагностики: зафиксировать безопасное evidence, различить неверный тип, absent и null, сужение старых значений и смену смысла. При несовместимой активной паре остановить producer, сохранить sample и добавить проверку в compatibility matrix.</desc>
<rect width="1200" height="1280" fill="#0b1322"/>
<rect x="48" y="42" width="1104" height="110" rx="24" fill="#342537" stroke="#ff9ab0" stroke-width="3"/>
<text x="92" y="96" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#ffffff">Reader упал после записи: диагностика</text>
<text x="92" y="130" font-family="Arial, sans-serif" font-size="25" fill="#ffd5dd">Сначала evidence, затем rollback или остановка producer</text>
<rect x="170" y="210" width="860" height="130" rx="22" fill="#193555" stroke="#77bcff" stroke-width="3"/>
<text x="212" y="264" font-family="Arial, sans-serif" font-size="32" font-weight="700" fill="#ffffff">1. Собрать безопасное evidence</text>
<text x="212" y="305" font-family="Arial, sans-serif" font-size="24" fill="#d5eaff">record id · writer/reader · field names · error path · state key</text>
<path d="M600 340 V414" stroke="#9fc8f0" stroke-width="7" fill="none" marker-end="url(#arrow)"/>
<polygon points="600,416 800,500 600,584 400,500" fill="#244b49" stroke="#78e1be" stroke-width="3"/>
<text x="510" y="493" font-family="Arial, sans-serif" font-size="28" font-weight="700" fill="#ffffff">Какой разрыв?</text>
<text x="496" y="528" font-family="Arial, sans-serif" font-size="21" fill="#cef8e9">type · presence · narrowing · semantic</text>
<path d="M472 555 L310 654" stroke="#ffb069" stroke-width="6" fill="none" marker-end="url(#arrowOrange)"/>
<path d="M558 580 L505 654" stroke="#80c4ff" stroke-width="6" fill="none" marker-end="url(#arrow)"/>
<path d="M642 580 L695 654" stroke="#f6d06f" stroke-width="6" fill="none" marker-end="url(#arrowYellow)"/>
<path d="M728 555 L890 654" stroke="#ff94a9" stroke-width="6" fill="none" marker-end="url(#arrowPink)"/>
<rect x="70" y="675" width="500" height="146" rx="20" fill="#4a3022" stroke="#ffb069" stroke-width="3"/>
<text x="104" y="727" font-family="Arial, sans-serif" font-size="31" font-weight="700" fill="#fff6ee">Type break</text>
<text x="104" y="766" font-family="Arial, sans-serif" font-size="24" fill="#ffe2ca">timezone: number → stop writer path</text>
<rect x="630" y="675" width="500" height="146" rx="20" fill="#1d3555" stroke="#80c4ff" stroke-width="3"/>
<text x="664" y="727" font-family="Arial, sans-serif" font-size="31" font-weight="700" fill="#ffffff">Presence</text>
<text x="664" y="766" font-family="Arial, sans-serif" font-size="24" fill="#d8efff">absent ≠ null → вернуть ветку reader</text>
<rect x="70" y="860" width="500" height="146" rx="20" fill="#4a3b21" stroke="#f6d06f" stroke-width="3"/>
<text x="104" y="912" font-family="Arial, sans-serif" font-size="31" font-weight="700" fill="#ffffff">Narrowing</text>
<text x="104" y="951" font-family="Arial, sans-serif" font-size="24" fill="#fff0bc">legacy daily rejected → restore range</text>
<rect x="630" y="860" width="500" height="146" rx="20" fill="#482633" stroke="#ff94a9" stroke-width="3"/>
<text x="664" y="912" font-family="Arial, sans-serif" font-size="31" font-weight="700" fill="#ffffff">Semantic</text>
<text x="664" y="951" font-family="Arial, sans-serif" font-size="24" fill="#ffe0e6">same string, new meaning → stop producer</text>
<path d="M320 821 V1037" stroke="#ffb069" stroke-width="6" fill="none" marker-end="url(#arrowOrange)"/>
<path d="M880 821 V1037" stroke="#80c4ff" stroke-width="6" fill="none" marker-end="url(#arrow)"/>
<path d="M320 1006 V1037" stroke="#f6d06f" stroke-width="6" fill="none" marker-end="url(#arrowYellow)"/>
<path d="M880 1006 V1037" stroke="#ff94a9" stroke-width="6" fill="none" marker-end="url(#arrowPink)"/>
<rect x="135" y="1060" width="930" height="146" rx="24" fill="#183b38" stroke="#79e1bd" stroke-width="3"/>
<text x="178" y="1110" font-family="Arial, sans-serif" font-size="33" font-weight="700" fill="#ffffff">2. Безопасное действие</text>
<text x="178" y="1150" font-family="Arial, sans-serif" font-size="25" fill="#d7fff0">Сохранить sample и добавить accept/reject в compatibility matrix.</text>
<text x="178" y="1184" font-family="Arial, sans-serif" font-size="22" fill="#aee8d4">Не переписывать record наугад: сначала проверить active writer → reader pair.</text>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 z" fill="#9fc8f0"/></marker>
<marker id="arrowOrange" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 z" fill="#ffb069"/></marker>
<marker id="arrowYellow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 z" fill="#f6d06f"/></marker>
<marker id="arrowPink" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 z" fill="#ff94a9"/></marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1280" viewBox="0 0 1200 1280" role="img" aria-labelledby="title desc">
<title id="title">Безопасная эволюция контракта profile.settings</title>
<desc id="desc">Вертикальная последовательность: зафиксировать version one и samples, добавить reader version two с различением absent и null, затем добавить writer version two с optional timezone и проверить матрицу. Внизу показан запрет на narrowing старых значений и смену семантики.</desc>
<rect width="1200" height="1280" fill="#0c1424"/>
<rect x="48" y="44" width="1104" height="112" rx="24" fill="#162946" stroke="#8ec5ff" stroke-width="3"/>
<text x="92" y="98" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#ffffff">Эволюция договора: reader раньше writer</text>
<text x="92" y="132" font-family="Arial, sans-serif" font-size="25" fill="#bed3ed">Каждый шаг имеет sample и проверяемый verdict</text>
<line x1="600" y1="226" x2="600" y2="1020" stroke="#7899bf" stroke-width="10" stroke-linecap="round"/>
<circle cx="600" cy="260" r="31" fill="#71b9ff"/>
<rect x="112" y="198" width="420" height="150" rx="20" fill="#1b3557" stroke="#71b9ff" stroke-width="3"/>
<text x="144" y="252" font-family="Arial, sans-serif" font-size="31" font-weight="700" fill="#ffffff">1. Зафиксировать v1</text>
<text x="144" y="292" font-family="Arial, sans-serif" font-size="24" fill="#d7e9ff">core-поля, samples, owner, daily</text>
<text x="144" y="325" font-family="Arial, sans-serif" font-size="22" fill="#aac9e8">не менять meaning задним числом</text>
<circle cx="600" cy="468" r="31" fill="#77e2bd"/>
<rect x="668" y="404" width="420" height="154" rx="20" fill="#19443d" stroke="#77e2bd" stroke-width="3"/>
<text x="700" y="458" font-family="Arial, sans-serif" font-size="31" font-weight="700" fill="#ffffff">2. Добавить reader v2</text>
<text x="700" y="498" font-family="Arial, sans-serif" font-size="24" fill="#dbfff2">v1 → timezone: absent</text>
<text x="700" y="531" font-family="Arial, sans-serif" font-size="22" fill="#aee8d4">null остаётся explicit clear</text>
<circle cx="600" cy="680" r="31" fill="#77e2bd"/>
<rect x="112" y="616" width="420" height="154" rx="20" fill="#19443d" stroke="#77e2bd" stroke-width="3"/>
<text x="144" y="670" font-family="Arial, sans-serif" font-size="31" font-weight="700" fill="#ffffff">3. Добавить writer v2</text>
<text x="144" y="710" font-family="Arial, sans-serif" font-size="24" fill="#dbfff2">+ optional timezone</text>
<text x="144" y="743" font-family="Arial, sans-serif" font-size="22" fill="#aee8d4">старый core и meaning сохранены</text>
<circle cx="600" cy="892" r="31" fill="#f5c768"/>
<rect x="668" y="828" width="420" height="154" rx="20" fill="#4b3b21" stroke="#f5c768" stroke-width="3"/>
<text x="700" y="882" font-family="Arial, sans-serif" font-size="31" font-weight="700" fill="#ffffff">4. Проверить matrix</text>
<text x="700" y="922" font-family="Arial, sans-serif" font-size="24" fill="#fff0bf">v1→v1, v1→v2, v2→v1, v2→v2</text>
<text x="700" y="955" font-family="Arial, sans-serif" font-size="22" fill="#f6d982">negative case: reject</text>
<path d="M600 300 V428" stroke="#71b9ff" stroke-width="7" fill="none" marker-end="url(#arrow)"/>
<path d="M600 508 V640" stroke="#77e2bd" stroke-width="7" fill="none" marker-end="url(#arrow)"/>
<path d="M600 720 V852" stroke="#f5c768" stroke-width="7" fill="none" marker-end="url(#arrow)"/>
<rect x="70" y="1070" width="1060" height="150" rx="24" fill="#3a2431" stroke="#ff91a8" stroke-width="3"/>
<text x="108" y="1128" font-family="Arial, sans-serif" font-size="31" font-weight="700" fill="#fff5f7">Стоп до rollout</text>
<text x="350" y="1128" font-family="Arial, sans-serif" font-size="27" fill="#ffe0e6">narrowing · required вместо optional</text>
<text x="108" y="1176" font-family="Arial, sans-serif" font-size="24" fill="#ffbecb">Новый смысл старого key: сначала transition и owner decision.</text>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 z" fill="#b9d4f3"/></marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

+659
View File
@@ -0,0 +1,659 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
}
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength);
}
return { ...meta, contentHtml, proseLength };
}
const rfc8259 = {
title: 'RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format',
url: 'https://www.rfc-editor.org/rfc/rfc8259.html',
note: 'первичный стандарт JSON: объект состоит из пар имя/значение; порядок членов объекта не следует использовать как межсистемный договор',
};
const jsonSchema201909 = {
title: 'JSON Schema draft 2019-09: Validation vocabulary',
url: 'https://json-schema.org/draft/2019-09/draft-handrews-json-schema-validation-02',
note: 'официально опубликованный Draft 2019-09 (17 сентября 2019, не IETF RFC) разделяет assertions для структуры и допускает тип <code>null</code>; конкретный валидатор и режим проверки остаются выбором приложения',
};
const avro1102 = {
title: 'Apache Avro 1.10.2: Specification, schema resolution',
url: 'https://avro.apache.org/docs/1.10.2/spec.pdf',
note: 'официальная спецификация формата описывает resolution writer и reader schema; это пример форматно-зависимого правила, а не свойство произвольного JSON-объекта',
};
const contractV1 = Object.freeze({
name: 'profile.settings',
revision: 'v1',
requiredFields: Object.freeze(['id', 'displayName', 'settings']),
optionalFields: Object.freeze([]),
knownFields: Object.freeze(['schema', 'id', 'displayName', 'settings']),
digestValues: Object.freeze(['off', 'weekly', 'daily']),
digestSemantic: 'summary-cadence-per-profile',
ignoresUnknownOptional: true,
});
const contractV2 = Object.freeze({
name: 'profile.settings',
revision: 'v2',
requiredFields: Object.freeze(['id', 'displayName', 'settings']),
optionalFields: Object.freeze(['timezone']),
knownFields: Object.freeze(['schema', 'id', 'displayName', 'settings', 'timezone']),
digestValues: Object.freeze(['off', 'weekly', 'daily']),
digestSemantic: 'summary-cadence-per-profile',
ignoresUnknownOptional: true,
});
const narrowedDigestReader = Object.freeze({
...contractV2,
revision: 'v2-proposed-narrowing',
digestValues: Object.freeze(['off', 'weekly']),
});
const semanticChangedReader = Object.freeze({
...contractV2,
revision: 'v2-proposed-semantic-change',
digestSemantic: 'marketing-segment-label',
});
const requiredTimezoneReader = Object.freeze({
...contractV2,
revision: 'v2-proposed-required-timezone',
requiredFields: Object.freeze(['id', 'displayName', 'settings', 'timezone']),
optionalFields: Object.freeze([]),
});
function hasOwn(value, key) {
return Object.prototype.hasOwnProperty.call(value, key);
}
function isObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function assertProfileCore(record) {
if (!isObject(record)) throw new Error('profile record must be an object');
if (typeof record.id !== 'string' || record.id.length === 0) {
throw new Error('profile.id must be a non-empty string');
}
if (typeof record.displayName !== 'string' || record.displayName.length === 0) {
throw new Error('profile.displayName must be a non-empty string');
}
if (!isObject(record.settings) || !contractV1.digestValues.includes(record.settings.emailDigest)) {
throw new Error('profile.settings.emailDigest is outside the documented contract');
}
}
function describeTimezone(record) {
if (!hasOwn(record, 'timezone')) return { state: 'absent' };
if (record.timezone === null) return { state: 'explicit-null' };
if (typeof record.timezone === 'string' && record.timezone.length > 0) {
return { state: 'value', value: record.timezone };
}
throw new Error('profile.timezone must be absent, null, or a non-empty string');
}
export function readProfileByConsumerV1(record) {
assertProfileCore(record);
return {
consumer: 'v1',
id: record.id,
displayName: record.displayName,
emailDigest: record.settings.emailDigest,
};
}
export function readProfileByConsumerV2(record) {
const base = readProfileByConsumerV1(record);
return { ...base, consumer: 'v2', timezone: describeTimezone(record) };
}
function compatibilityIssues(writer, reader) {
const issues = [];
for (const field of reader.requiredFields) {
if (!writer.requiredFields.includes(field)) {
issues.push('presence break: reader requires ' + field + ', writer may omit it');
}
}
for (const field of writer.requiredFields) {
if (!reader.knownFields.includes(field)) {
issues.push('reader has no rule for writer required field ' + field);
}
}
for (const field of writer.optionalFields) {
if (!reader.knownFields.includes(field) && !reader.ignoresUnknownOptional) {
issues.push('reader rejects writer optional field ' + field);
}
}
for (const value of writer.digestValues) {
if (!reader.digestValues.includes(value)) {
issues.push('narrowing break: reader rejects prior emailDigest value ' + value);
}
}
if (writer.digestSemantic !== reader.digestSemantic) {
issues.push('semantic break: emailDigest keeps a string shape but changes meaning');
}
return issues;
}
function rejects(action) {
try {
action();
return false;
} catch {
return true;
}
}
function reorderV2Record(record) {
return {
settings: record.settings,
timezone: record.timezone,
displayName: record.displayName,
id: record.id,
schema: record.schema,
};
}
/**
* Учебный контракт в одном Node-процессе. Здесь нет JSON-файла, базы, broker,
* настоящего migration job или сетевого reader. Fixture проверяет только
* явно записанные правила profile.settings между v1 и v2.
*/
export function runStorageContractFixture() {
const producerV1 = {
schema: 'profile.settings/v1',
id: 'profile-17',
displayName: 'Mira',
settings: { emailDigest: 'weekly' },
};
const producerV1Daily = {
schema: 'profile.settings/v1',
id: 'profile-18',
displayName: 'Nora',
settings: { emailDigest: 'daily' },
};
const producerV2 = {
schema: 'profile.settings/v2',
id: 'profile-17',
displayName: 'Mira',
settings: { emailDigest: 'weekly' },
timezone: 'Europe/Moscow',
};
const producerV2Clear = {
schema: 'profile.settings/v2',
id: 'profile-17',
displayName: 'Mira',
settings: { emailDigest: 'weekly' },
timezone: null,
};
const missingId = {
schema: 'profile.settings/v1',
displayName: 'Mira',
settings: { emailDigest: 'weekly' },
};
const wrongTimezoneType = {
schema: 'profile.settings/v2',
id: 'profile-17',
displayName: 'Mira',
settings: { emailDigest: 'weekly' },
timezone: 3,
};
const invalidDigestValue = {
schema: 'profile.settings/v1',
id: 'profile-17',
displayName: 'Mira',
settings: { emailDigest: 'hourly' },
};
const matrix = [
{ pair: 'writer v1 → reader v1', issues: compatibilityIssues(contractV1, contractV1) },
{ pair: 'writer v2 additive → reader v1', issues: compatibilityIssues(contractV2, contractV1) },
{ pair: 'writer v1 → reader v2', issues: compatibilityIssues(contractV1, contractV2) },
{ pair: 'writer v2 → reader v2', issues: compatibilityIssues(contractV2, contractV2) },
{ pair: 'writer v1 → proposed narrowed reader', issues: compatibilityIssues(contractV1, narrowedDigestReader) },
{ pair: 'writer v2 → proposed semantic reader', issues: compatibilityIssues(contractV2, semanticChangedReader) },
{ pair: 'writer v1 → proposed required-timezone reader', issues: compatibilityIssues(contractV1, requiredTimezoneReader) },
];
const matrixByPair = new Map(matrix.map((row) => [row.pair, row.issues]));
const v1ByV1 = readProfileByConsumerV1(producerV1);
const v2ByV1 = readProfileByConsumerV1(producerV2);
const v1ByV2 = readProfileByConsumerV2(producerV1);
const v2ByV2 = readProfileByConsumerV2(producerV2);
const clearByV2 = readProfileByConsumerV2(producerV2Clear);
const reorderedByV2 = readProfileByConsumerV2(reorderV2Record(producerV2));
const assertions = {
v1WriterReadsByV1: v1ByV1.id === 'profile-17' && v1ByV1.emailDigest === 'weekly',
additiveWriterV2ReadsByV1: matrixByPair.get('writer v2 additive → reader v1').length === 0
&& v2ByV1.id === producerV2.id && v2ByV1.emailDigest === 'weekly',
oldWriterReadsByV2AsAbsent: matrixByPair.get('writer v1 → reader v2').length === 0
&& v1ByV2.timezone.state === 'absent',
explicitNullDoesNotBecomeAbsent: clearByV2.timezone.state === 'explicit-null',
timezoneValueStaysAValue: v2ByV2.timezone.state === 'value' && v2ByV2.timezone.value === 'Europe/Moscow',
orderIsNotReadAsContract: reorderedByV2.id === v2ByV2.id
&& reorderedByV2.emailDigest === v2ByV2.emailDigest
&& reorderedByV2.timezone.value === v2ByV2.timezone.value,
requiredFieldMissingIsRejected: rejects(() => readProfileByConsumerV1(missingId)),
typeBreakIsRejected: rejects(() => readProfileByConsumerV2(wrongTimezoneType)),
narrowingIsRejectedByMatrix: matrixByPair.get('writer v1 → proposed narrowed reader')
.some((issue) => issue.startsWith('narrowing break')),
semanticChangeIsRejectedByMatrix: matrixByPair.get('writer v2 → proposed semantic reader')
.some((issue) => issue.startsWith('semantic break')),
presenceBreakIsRejectedByMatrix: matrixByPair.get('writer v1 → proposed required-timezone reader')
.some((issue) => issue.startsWith('presence break')),
genericJsonDoesNotBypassTheContract: rejects(() => readProfileByConsumerV1(invalidDigestValue)),
matrixKeepsTheDailyLegacyCase: producerV1Daily.settings.emailDigest === 'daily'
&& matrixByPair.get('writer v1 → proposed narrowed reader').length > 0,
};
if (!Object.values(assertions).every(Boolean)) {
throw new Error('storage contract fixture violated a documented invariant');
}
return {
model: 'local in-memory profile.settings contract; not storage, JSON parser, migration service, or provider guarantee',
contracts: { writerV1: contractV1, writerV2: contractV2 },
records: { producerV1, producerV1Daily, producerV2, producerV2Clear, invalidDigestValue },
reads: { v1ByV1, v2ByV1, v1ByV2, v2ByV2, clearByV2, reorderedByV2 },
matrix,
assertions,
};
}
const contractSketchCode = [
'const profileSettingsContract = {',
" owner: 'profile-settings',",
" identity: 'profile.id',",
" required: ['id', 'displayName', 'settings.emailDigest'],",
" optional: { timezone: 'absent | null | non-empty string' },",
" meaning: { emailDigest: 'summary cadence, not a marketing label' },",
" readers: ['v1 ignores optional unknown fields', 'v2 distinguishes absent and null'],",
" rollout: 'reader capability → additive writer → compatibility test',",
'};',
].join('\n');
const presenceCode = [
'const owns = (value, key) => Object.prototype.hasOwnProperty.call(value, key);',
'',
'function timezoneState(record) {',
" if (!owns(record, 'timezone')) return 'absent';",
" if (record.timezone === null) return 'explicit-null';",
" if (typeof record.timezone === 'string' && record.timezone) return 'value';",
" throw new Error('timezone violates contract');",
'}',
'',
"timezoneState({ id: 'profile-17' }); // absent",
"timezoneState({ id: 'profile-17', timezone: null }); // explicit-null",
].join('\n');
const compatibilityCode = [
"const result = compatibilityIssues(writerV1, proposedReader);",
"if (result.some((issue) => issue.startsWith('narrowing break'))) {",
" throw new Error('do not release a reader that rejects legacy daily');",
'}',
'',
"// Same JSON type is still not enough:",
"// emailDigest: 'weekly' must keep summary-cadence semantics.",
].join('\n');
const rolloutCode = [
'const matrix = [',
" ['writer v1', 'reader v1', 'accept'],",
" ['writer v1', 'reader v2', 'accept: timezone absent'],",
" ['writer v2 additive', 'reader v1', 'accept: unknown optional ignored'],",
" ['writer v2', 'reader v2', 'accept: null is explicit clear'],",
" ['writer v1 daily', 'narrowed reader', 'reject before rollout'],",
'];',
].join('\n');
const diagnosisCode = [
'const owns = (value, key) => Object.prototype.hasOwnProperty.call(value, key);',
'',
'function collectReaderEvidence(record, error) {',
' return {',
' recordId: record.id,',
' schema: record.schema,',
' fieldNames: Object.keys(record).sort(),',
' errorPath: error.path,',
" timezoneState: owns(record, 'timezone')",
" ? (record.timezone === null ? 'explicit-null' : typeof record.timezone)",
" : 'absent',",
' };',
'}',
'',
'// Значения profile и адреса не выводим: для диагноза нужны контракт и путь ошибки.',
].join('\n');
const fixtureCode = [
'const fixture = runStorageContractFixture();',
'for (const [name, passed] of Object.entries(fixture.assertions)) {',
" if (!passed) throw new Error('failed invariant: ' + name);",
'}',
'',
'// Fixture использует только Array, Map и объекты в памяти.',
'// Она не проверяет выбранное хранилище, репликацию или реальный rollout.',
].join('\n');
const sources = [rfc8259, jsonSchema201909, avro1102];
const practiceArticle = createRevision(
{
slug: 'editorial-2021-08-practice-storage-contracts',
title: 'Данные. Контракт хранилища: как менять profile/settings без внезапного разрыва',
categories: ['Данные', 'Инфраструктура', 'Практика'],
cover: '/assets/editorial/2021/storage-contract-compatibility-2021.svg',
excerpt: 'Контракт записи — это не только JSON-поля. Разбираю владельца, границу absent/null, поддержку старых reader и writer, а также маленькую compatibility matrix до безопасного rollout.',
readingMinutes: 14,
},
[
paragraph('Проблема начинается не в момент большой миграции, а после небольшой записи. Producer добавил поле <code>timezone</code> в <code>profile/settings</code>, reader увидел незнакомое состояние или решил, что отсутствующее поле равно <code>null</code>. Цена такой догадки — не красивый exception, а уже записанная версия профиля, которую старый код трактует иначе. Затем команда спорит о формате, хотя причина лежит в неописанном договоре между тем, кто пишет, и тем, кто читает.'),
paragraph('Для августа 2021 года я бы не называл JSON контрактом. JSON даёт синтаксис объекта, но не владельца поля, не смысл строки, не срок поддержки старого reader и не порядок отката. В этой заметке договор строится вокруг одного нейтрального record <code>profile.settings</code>. Пример локальный: он работает только в памяти и не моделирует БД, файл, broker, репликацию или реальную миграцию.'),
heading('Сначала записываем границу договора'),
paragraph('У договора есть объект и владелец. Объект отвечает на вопрос, что именно меняется: в нашем случае настройки одного профиля, а не «пользовательские данные вообще». Владелец отвечает на другой вопрос: кто разрешает новый смысл поля, совместимость старого reader и момент, когда старый writer можно выключить. Если это не записано рядом со схемой, любое изменение выглядит локальным до первого consumer, который был собран раньше.'),
paragraph('Минимум полезных частей: стабильный идентификатор, обязательные поля, необязательные поля, состояние <code>absent</code>, значение <code>null</code>, допустимые значения и человеческий смысл. Для <code>emailDigest</code> недостаточно написать «строка». В нашем договоре это режим частоты сводки: <code>off</code>, <code>weekly</code> или <code>daily</code>. Если завтра тем же словом начинают помечать маркетинговый сегмент, тип остаётся строкой, а смысл уже сломан.'),
codeBlock(contractSketchCode),
dataTable(
'Минимальная карточка договора profile.settings',
['Часть', 'Кто задаёт правило', 'Что проверяем до записи', 'Что не обещает правило'],
[
['<code>id</code>', 'owner record', 'непустая строка и связь с одним профилем', 'что профиль существует в выбранном хранилище'],
['<code>settings.emailDigest</code>', 'owner значения и их смысла', 'значение входит в записанный набор', 'что строка сама раскрывает бизнес-смысл'],
['<code>timezone</code>', 'owner presence semantics', 'absent, <code>null</code> или непустая строка различены', 'что absent можно бездумно заменить на <code>null</code>'],
['версия reader/writer', 'владелец rollout', 'compatibility matrix покрывает поддерживаемые пары', 'что любой старый consumer узнает новые поля'],
],
),
paragraph('Таблица намеренно не называет конкретный storage. Контракт живёт выше него: одна реализация может держать запись в документе, другая в строке или в сообщении. Смена места не отменяет правила чтения. И наоборот: выбранный сериализатор не делает смысл поля проверяемым. Поэтому owner должен хранить не только схему, но и список поддерживаемых направлений: старый writer → новый reader, новый writer → старый reader и новая пара.'),
heading('Absent и null — два разных входа'),
paragraph('Необязательное поле имеет минимум три состояния: ключ не пришёл, ключ пришёл с <code>null</code>, ключ пришёл со значением. Для старого writer отсутствие <code>timezone</code> означает только то, что он её не передал. Это не разрешение подставить «часовой пояс очищен». <code>null</code> в данной модели, наоборот, является явной командой очистки. Если приложение выбирает другую семантику, её нужно записать и проверить тем же способом.'),
codeBlock(presenceCode),
paragraph('В JavaScript важно проверять наличие собственного ключа, а не правдивость значения. Условие <code>if (record.timezone)</code> смешает пустую строку, <code>null</code> и отсутствие поля; в нашем договоре все три случая требуют разных решений. Пустая строка не допускается вовсе, потому что она не обозначена как отдельное состояние. Такой запрет полезнее умного fallback: reader либо видит известный случай, либо останавливает интерпретацию и оставляет evidence для исправления.'),
figure(
'/assets/editorial/2021/storage-contract-compatibility-2021.svg',
'Схема совместимости profile.settings: writer v1 и writer v2, reader v1 и reader v2; v2 добавляет необязательное timezone, старый reader игнорирует его, новый reader различает absent, null и строковое значение',
'Additive-поле безопасно только для явно проверенной пары reader и writer. Стрелки на схеме не являются гарантией выбранного хранилища.',
),
heading('Поддержка старых чтений и записей — это матрица, а не надежда'),
paragraph('Перед rollout стоит назвать пары, которые действительно будут жить одновременно. В fixture writer v1 пишет только core-поля. Writer v2 добавляет <code>timezone</code> как optional. Reader v1 читает именованные core-поля и игнорирует неизвестный optional ключ. Reader v2 умеет прочитать старую запись и вернуть состояние <code>absent</code>, не изображая его очищенным значением. Именно эти четыре направления становятся тестом, а не устным обещанием.'),
codeBlock(rolloutCode),
paragraph('Здесь есть важная граница: «игнорировать неизвестное» допустимо только для поля, которое не меняет старое обязательное поведение. Нельзя добавить новое поле, а затем сделать старый <code>emailDigest</code> зависимым от него без обновления reader. Тогда поле выглядит additive по форме, но становится semantic break. Так же нельзя сузить множество старых значений: если v1 писал <code>daily</code>, новый reader, который принимает только <code>off</code> и <code>weekly</code>, обязан быть отклонён migration test до релиза.'),
heading('Безопасный rollout идёт от reader к writer'),
paragraph('Порядок короткий. Сначала фиксируем старый договор и примеры старых записей. Затем добавляем reader, который понимает старый record и новое optional поле. Только после этого writer начинает посылать новое поле. Пока старый reader остаётся в поддерживаемой матрице, writer не должен превращать optional поле в обязательное и не должен менять смысл существующих значений. Удаление старого пути — отдельное изменение: для него нужны данные, что соответствующей пары больше нет, а не просто новая дата в схеме.'),
dataTable(
'Порядок изменения без предположения о платформе',
['Шаг', 'Изменение', 'Проверка', 'Стоп-сигнал'],
[
['1', 'зафиксировать v1 и samples', 'v1 reader читает каждый sample', 'непонятен смысл или owner старого поля'],
['2', 'добавить v2 reader', 'v1 record даёт <code>timezone: absent</code>', 'reader подставляет <code>null</code> без правила'],
['3', 'добавить v2 writer', 'v1 reader читает core, v2 reader читает значение и clear', 'новое поле стало обязательным для старого пути'],
['4', 'предложить удаление legacy', 'matrix не содержит поддерживаемую старую пару', 'есть record или consumer вне доказанной матрицы'],
],
),
paragraph('Rollback тоже должен знать границу. Если writer только добавил optional поле, можно остановить его выпуск и оставить reader совместимым с уже появившимися record. Если writer переиспользовал существующее значение с новым смыслом, простая отмена кода не возвращает старое значением прежний смысл. В таком случае сначала останавливают producer, сохраняют примеры и запускают отдельный контролируемый перевод данных. В нашем локальном примере такого перевода нет; он специально не выдаётся за готовую migration procedure.'),
heading('Fixture превращает правило в проверяемую границу'),
paragraph('Функция <code>runStorageContractFixture()</code> создаёт v1, additive v2 и v2 с явным <code>null</code>. Она читает их двумя consumer, переставляет ключи объекта, проверяет matrix и намеренно предлагает три плохих reader: с narrowed набором <code>emailDigest</code>, со сменой смысла и с обязательным <code>timezone</code>. У каждой гарантии есть assertion. Это не проверка сериализатора и не тест выбранной БД; это маленькое место, где изменение договора получает наблюдаемый ответ.'),
codeBlock(fixtureCode),
paragraph('Отдельный плюс fixture — она защищает от красивых, но пустых слов. Нельзя сказать, что generic JSON «эволюционно совместим»: код принимает или отклоняет только правила, которые мы сами описали. Нельзя сказать, что объект упорядочен как contract: reader обращается к именам полей, а перестановка ключей даёт тот же результат. Нельзя сказать, что <code>null</code> равен отсутствию: обе ветви возвращают разные состояния. Когда правило меняется, рядом меняется assertion и matrix.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'Зафиксировать симптом: какой reader, какой record id, какая пара writer/reader и на каком поле разошлась интерпретация. Не начинать с массового переписывания данных.',
'Назвать owner и ожидаемый смысл поля. Для <code>timezone</code> отдельно ответить, что означают absent, <code>null</code> и строка.',
'Проверить текущую compatibility matrix на старых и новых samples. Особо проверить направление старый writer → новый reader.',
'Если изменение additive, сначала выпустить reader, затем writer. Если поле становится required или меняет смысл, остановить rollout и оформить отдельный договор перехода.',
'Добавить failing sample для narrowing, type break и semantic break. Без отрицательного примера тест проверяет только удачный путь.',
'Удалять старую поддержку только после явной границы поддержки. До этого сохранять reader, который понимает уже записанные record.',
]),
heading('Пределы модели и источники'),
paragraph('RFC 8259 описывает JSON-объект как набор пар имя/значение и предупреждает о различиях реализации вокруг порядка членов. Поэтому порядок ключей в этой статье не является договором. JSON Schema draft 2019-09 полезен как язык structural assertions, но выбор валидатора и семантика приложения остаются с владельцем record. Apache Avro показывает другой, форматно-зависимый случай: у него есть writer и reader schema resolution. Нельзя переносить это правило на любой JSON лишь потому, что оба примера выглядят как данные.'),
paragraph('В пакете не запускаются storage engine, реальные файлы, сети, schema registry, миграционный job, browser, CI или deployment. Нет SLA, production-метрик и заявления, что конкретный rollout уже происходил. Следующий практический шаг — взять один настоящий record без чувствительных значений, записать его owner и поддерживаемые направления чтения, затем перенести эти samples в локальный compatibility test. После этого можно обсуждать конкретное хранилище, а не наоборот.'),
],
sources,
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2021-08-mechanism-storage-contracts',
title: 'Под капотом: совместимость контракта хранилища без догадок о JSON',
categories: ['Данные', 'Архитектура', 'Отладка'],
cover: '/assets/editorial/2021/storage-contract-evolution-2021.svg',
excerpt: 'Разделяю форму записи, presence, тип и смысл поля. На одной fixture показываю producer/consumer v1/v2, matrix совместимости и причины, по которым narrowing или semantic change нужно отклонить.',
readingMinutes: 15,
},
[
paragraph('Сбой reader после обычной записи часто выглядит как ошибка хранения: объект прочитан, но поле пришло «не таким». Цена неверного диагноза выше одного exception. Разработчик подставляет default, вторая версия writer закрепляет эту догадку, а старые record начинают означать другое. Через несколько изменений уже невозможно ответить, была ли <code>timezone</code> очищена, не передана старым writer или испорчена при преобразовании.'),
paragraph('Механизм совместимости начинается с простого разделения. Формат отвечает, как передать значения. Schema описывает разрешённую форму. Контракт добавляет presence и смысл. Consumer отвечает за интерпретацию конкретной версии. В этой статье я не запускаю parser, storage или migration framework: есть только Array, Map и объекты в одной Node fixture. Она показывает, какие assertions нужны до того, как формат или хранилище выбраны как решение.'),
heading('Четыре слоя, которые нельзя смешивать'),
paragraph('Первый слой — синтаксис JSON: есть object, member и literal <code>null</code>. Второй — structural validation: обязательность ключа, допустимый тип, набор значений. Третий — compatibility rule между writer и reader. Четвёртый — semantic rule: что означает строка <code>weekly</code> и можно ли переиспользовать её как другой признак. Если вопрос попал не на свой слой, решение оказывается слишком сильным или слишком слабым: например, schema разрешила строку, а consumer уже не может сказать, что эта строка обозначает.'),
dataTable(
'Где живёт каждое правило profile.settings',
['Слой', 'Пример правила', 'Какой дефект ловит', 'Чего не ловит сам'],
[
['формат', 'объект содержит пары имя/значение', 'неразбираемый текст', 'смысл и поддерживаемые версии'],
['shape', '<code>id</code> — непустая строка; <code>timezone</code> — absent, <code>null</code> или строка', 'type и presence break', 'переосмысление старого значения'],
['compatibility', 'v2 writer добавляет только unknown optional поле', 'разрыв пары writer/reader', 'фактический rollout вне теста'],
['semantic', '<code>weekly</code> — cadence сводки', 'та же строка с новым значением', 'право owner принять бизнес-решение'],
],
),
paragraph('Такое разделение делает ошибку локализуемой. Если <code>timezone: 3</code>, это type break: reader обязан отвергнуть record. Если ключа нет, это presence case: reader v2 возвращает <code>absent</code>, но не «очищено». Если новый reader перестал принимать старое <code>daily</code>, это narrowing: у writer v1 было законное значение, а новый reader сузил договор. Если <code>weekly</code> сохранило тип, но стало обозначать маркетинговый флаг, это semantic break. Ни один JSON parser не может угадать последнее правило.'),
heading('Absent, null и значение должны пройти разными ветками'),
paragraph('Отсутствующий key и key со значением <code>null</code> различаются уже в JSON-представлении. Но их бизнес-граница выбирается приложением. В учебном договоре absent означает «writer этой версии не сообщил timezone», а <code>null</code> — «writer явно очистил timezone». Поэтому reader v2 не имеет права свести оба случая к одной переменной без состояния. Иначе переход от v1 к v2 будет выглядеть успешным, но потеряет информацию, которую позже нельзя восстановить.'),
codeBlock(presenceCode),
paragraph('Обратите внимание на проверку собственного свойства. У объекта могут быть прототип, вычисляемый default или неудачный merge; договор читает конкретный record, а не всё, что JavaScript способен вернуть по цепочке. Для этого примера пустая строка тоже отвергается: она не является ни старым absent, ни явным clear, ни полезным часовым поясом. В реальном проекте допустимые строки и их нормализацию задаёт owner отдельно; fixture не притворяется справочником временных зон.'),
figure(
'/assets/editorial/2021/storage-contract-evolution-2021.svg',
'Последовательность эволюции profile.settings: зафиксированный v1, reader v2 с поддержкой absent и null, additive writer v2, compatibility matrix и красная граница для narrowing либо переиспользования семантики',
'У reader появляется способность понять старые и новые record раньше, чем writer начинает выпускать новый optional key.',
),
heading('Порядок ключей не является договором record'),
paragraph('RFC 8259 не даёт переносимого права использовать порядок членов object как смысл. Реальная библиотека может сохранить insertion order, другая — показать свои структуры иначе; reader, который зависит от позиции, перестаёт быть договором по именам. В fixture функция <code>reorderV2Record()</code> меняет порядок тех же key. Reader v2 получает тот же id, <code>emailDigest</code> и timezone value, потому что читает имя key, а не его место. Это assertion о нашем reader, не обещание одинакового поведения всех библиотек.'),
codeBlock([
'const reordered = {',
' settings: record.settings,',
' timezone: record.timezone,',
' displayName: record.displayName,',
' id: record.id,',
'};',
'',
'const before = readProfileByConsumerV2(record);',
'const after = readProfileByConsumerV2(reordered);',
'// before.id === after.id; reader обращается к именам полей',
].join('\n')),
paragraph('Это правило имеет исключение только там, где формат сам фиксирует порядок: например, позиционный массив или отдельный бинарный protocol. Тогда порядок должен быть назван в contract и иметь собственный тест. Для обычного object такой перенос смысла создаёт невидимый coupling: producer переставил поля ради читаемости, а consumer вдруг прочитал другой record. Вместо позиции используйте name, explicit discriminator или отдельный массив, если порядок действительно является данными.'),
heading('Compatibility matrix проверяет направление, а не слово «v2»'),
paragraph('Версия не гарантирует совместимость. Нужна направленная проверка: может ли конкретный reader прочитать record конкретного writer. В нашей matrix четыре ожидаемо зелёные пары: v1 → v1, v2 additive → v1, v1 → v2, v2 → v2. Пара v2 writer → v1 reader разрешена только потому, что <code>timezone</code> описано как optional, а v1 reader явно игнорирует unknown optional fields. Если бы v1 reader запрещал все незнакомые ключи, та же запись стала бы несовместимой.'),
codeBlock(compatibilityCode),
dataTable(
'Migration matrix и ожидаемый verdict fixture',
['Writer', 'Reader', 'Ожидание', 'Почему'],
[
['v1 без <code>timezone</code>', 'v1', 'accept', 'одинаковый core-договор'],
['v2 с optional <code>timezone</code>', 'v1', 'accept', 'reader игнорирует только неизвестный optional key'],
['v1 без <code>timezone</code>', 'v2', 'accept', 'v2 выдаёт состояние <code>absent</code>'],
['v1 с <code>daily</code>', 'proposed narrowed v2', 'reject', 'reader удалил прежнее допустимое значение'],
['v2 с прежней формой', 'proposed semantic v2', 'reject', 'строка сохраняется, но meaning изменился'],
['v1 без <code>timezone</code>', 'proposed required-timezone reader', 'reject', 'старый writer вправе не присылать key'],
],
),
paragraph('Матрица не должна быть составлена из одних «зелёных» examples. Плохие пары важнее: они доказывают, что тест способен остановить опасную правку. В нашем коде proposal с narrowed <code>emailDigest</code> отвергается, потому что legacy sample содержит <code>daily</code>. Proposal с другой semantic меткой также отвергается, хотя набор JavaScript-типов прежний. Это делает обсуждение точным: нужно не «аккуратно менять схему», а решить, поддерживается ли старое значение и старый смысл.'),
heading('Schema validation не заменяет migration test'),
paragraph('JSON Schema draft 2019-09 позволяет выражать structural assertions, включая типы и ограничения. Это полезно для входа, но schema не знает сама по себе, какие версии writer ещё существуют, кто может удалить old path и что означает <code>weekly</code>. Даже форматно-зависимый механизм вроде Avro schema resolution не переносится автоматически на JSON objects: его правила относятся к конкретной паре writer/reader schema и выбранному format. Поэтому migration test содержит samples и направленную matrix рядом с документом, а не прячется в названии версии.'),
paragraph('Для нашей модели есть ещё одна граница. Assertion <code>genericJsonDoesNotBypassTheContract</code> проверяет строку <code>emailDigest: "hourly"</code>: JSON-форма корректна, но значение отсутствует в договоре и reader fixture его отвергает. Он не доказывает свойства любой JSON-библиотеки, persistence layer или schema registry. Если выбранный инструмент валидирует вход иначе, его поведение нужно добавить отдельным integration test. Маленькая fixture полезна тем, что сначала фиксирует собственное правило и не выдаёт локальную проверку за системную гарантию.'),
heading('Рабочая fixture и её assertions'),
paragraph('Один запуск создаёт producer v1, producer v2, v2 с clear и legacy sample с <code>daily</code>. Затем он строит Map matrix, читает record двумя consumer и намеренно подаёт missing id, числовой timezone, narrowed reader, semantic reader и required timezone reader. Assertion не просто перечисляет слова: он проверяет конкретную пару, состояние или факт rejection. Если новый change проходит только потому, что fixture ничего о нём не знает, это не совместимость, а отсутствующая проверка.'),
codeBlock(fixtureCode),
paragraph('Управление изменением остаётся простым: добавили правило — добавили sample и assertion; изменили meaning — изменили semantic marker и решили, нужен ли отдельный field; сделали поле required — доказали, что старые writer больше не входят в матрицу. Такой ход не даёт универсального migration framework, зато сохраняет техническую границу рядом с кодом. В 2021 году это важнее громкого названия: следующий reader сможет объяснить, почему он принимает record, а не просто «как-то переживает v2». '),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'Взять один record, на котором reader упал, и выписать key names, schema label, путь ошибки и ожидаемую пару writer/reader. Значения профиля в диагностический вывод не добавлять.',
'Классифицировать разрыв: type, presence, narrowing или semantic. Не называть отсутствие key ошибкой типа.',
'Проверить absent и <code>null</code> через наличие собственного key. Если оба состояния сейчас сливаются, остановить изменение до выбора семантики.',
'Переставить key в локальном object. Если result меняется, consumer читает порядок, который JSON object не обязан гарантировать.',
'Добавить пару в migration matrix и требовать ожидаемый accept либо reject. Negative case должен оставаться в fixture после исправления.',
'Только затем выбрать validator, storage или форматный механизм. Их integration test дополняет, но не заменяет contract test.',
]),
heading('Ограничения и проверяемые источники'),
paragraph('Эта модель не утверждает, что JSON Schema, Avro или любое хранилище автоматически сохраняют совместимость. RFC 8259 задаёт синтаксическую рамку и предостерегает от зависимости от порядка object members. JSON Schema draft 2019-09 задаёт vocabulary для structural validation, а не готовую политику rollout. Avro 1.10.2 показывает, что schema resolution требует конкретных writer и reader schema. В статье эти источники используются для границы терминов, а не для заявления о запущенной production-инфраструктуре.'),
paragraph('Fixture не запускает БД, broker, файлы, SDK, реальную миграцию, сеть, browser, CI или deployment. Она не проверяет rights, backfill, retention и скорость обработки. Следующий шаг для проекта — перенести ровно эту matrix на реальные samples выбранного формата, добавить integration test его validator и отдельно описать owner решения о старом reader. До этого «v2» остаётся названием, а не доказательством совместимости.'),
],
sources,
);
const fieldArticle = createRevision(
{
slug: 'editorial-2021-08-field-storage-contracts',
title: 'Разбор: reader упал после записи — как диагностировать контракт хранилища',
categories: ['Данные', 'Отладка', 'Надёжность'],
cover: '/assets/editorial/2021/storage-contract-diagnosis-2021.svg',
excerpt: 'Полевой маршрут для случая, когда reader падает после новой записи: какие evidence собрать, когда остановить producer, какой rollback безопасен и как вернуть изменение в compatibility test.',
readingMinutes: 15,
},
[
paragraph('Симптом выглядит коротко: reader упал сразу после записи <code>profile/settings</code>. В логе видно «unexpected value», а у producer уже есть новая версия. Цена поспешного исправления — не только повторный сбой. Если сейчас переписать record, подставить default или включить прежний код без проверки, можно стереть различие между отсутствующим key, явным <code>null</code> и новым смыслом старой строки. Тогда откат станет похож на исправление, но потеряет evidence.'),
paragraph('Ниже — полевой маршрут для учебного record, а не отчёт о production-инциденте. Он помогает разделить четыре причины: type break, presence break, narrowing и semantic change. Моя fixture работает в одном Node-процессе с Array, Map и objects. Она не читает реальное хранилище и не умеет останавливать service; слова «остановить producer» здесь означают безопасное действие, которое владелец конкретной системы должен выполнить своими средствами после проверки границы.'),
heading('Сначала сохраняем evidence, а не меняем запись'),
paragraph('Для первого диагноза нужны record id, label версии writer, имя reader, путь ошибки, перечень key и состояние спорного поля. Сами значения профиля не обязательны и часто не должны попадать в общий лог. Если ошибка на <code>timezone</code>, достаточно различить absent, <code>null</code>, строку и неверный type. Если ошибка на <code>emailDigest</code>, нужен старый список допустимых значений и смысл, который reader ожидал. Это даёт проверяемую гипотезу до rollback.'),
codeBlock(diagnosisCode),
paragraph('Перечень key лучше сортировать только для стабильного evidence, а не использовать как порядок contract. RFC 8259 не обещает переносимую семантику порядка object members. Мы фиксируем, что <code>timezone</code> был или не был передан, но не делаем вывод из того, шёл ли он до <code>settings</code>. Для защищённых или персональных record hash, redaction и политика доступа добавляются в конкретной системе; fixture таких механизмов не изображает.'),
dataTable(
'Первая классификация падения reader',
['Симптом', 'Evidence', 'Вероятная граница', 'Безопасное первое действие'],
[
['<code>timezone</code> имеет число', 'key есть, type <code>number</code>', 'type break', 'остановить новый writer для этого значения; не подставлять строку наугад'],
['старый record не содержит <code>timezone</code>', 'key отсутствует, v1 sample', 'presence break', 'вернуть reader ветку <code>absent</code>, не писать <code>null</code> поверх record'],
['legacy <code>daily</code> отвергнут', 'v1 writer и старое допустимое значение', 'narrowing', 'отменить новый reader или расширить его договор; не менять legacy record массово'],
['<code>weekly</code> прочитан, но эффект другой', 'shape совпадает, meaning расходится', 'semantic break', 'остановить producer, который переиспользует значение; оформить отдельный field или migration plan'],
],
),
heading('Различаем invalid value и неизвестное состояние'),
paragraph('Типовой соблазн — сделать всё optional: если reader не понял поле, он молча берёт default. Это допустимо лишь когда default уже является частью договора и не скрывает факт. В нашем примере <code>timezone: 3</code> — invalid value, поэтому reader v2 выбрасывает ошибку. Отсутствующая <code>timezone</code> — допустимый legacy state, поэтому reader возвращает <code>{ state: "absent" }</code>. <code>null</code> — отдельная явная команда clear. Три ветки нужны, чтобы stop/rollback был основан на причине, а не на удобстве кода.'),
codeBlock([
'const oldRecord = { id: "profile-17", settings: { emailDigest: "weekly" } };',
'const clearRecord = { ...oldRecord, timezone: null };',
'const brokenRecord = { ...oldRecord, timezone: 3 };',
'',
'readProfileByConsumerV2(oldRecord).timezone.state; // absent',
'readProfileByConsumerV2(clearRecord).timezone.state; // explicit-null',
'readProfileByConsumerV2(brokenRecord); // throws',
].join('\n')),
paragraph('Если actual reader не показывает эту разницу, сначала правят reader или договор, а не record. Перезапись absent в <code>null</code> создаёт видимость, что пользователь явно очистил значение. Превращение ошибочного числа в произвольную строку создаёт ещё один semantic guess. Обе правки усложняют расследование: следующие reader уже увидят синтетическое значение и не смогут отличить его от того, что producer действительно отправил.'),
figure(
'/assets/editorial/2021/storage-contract-diagnosis-2021.svg',
'Диагностическое дерево падения reader после записи profile.settings: собрать безопасное evidence, отличить type, absence/null, narrowing и semantic break; при рискованном изменении остановить producer, сохранить sample и вернуть изменение в compatibility matrix',
'Путь заканчивается действием только после классификации. Остановка producer не равна удалению record и не отменяет необходимость сохранить evidence.',
),
heading('Когда producer нужно остановить'),
paragraph('Останавливать producer разумно, когда он продолжает создавать record, которые reader не может безопасно интерпретировать, или когда он меняет значение существующего key с новым смыслом. В type break это ограничивает появление новых неправильных record. В semantic break это останавливает смешение старого и нового meaning под одним словом. При ordinary additive поле, которое старый reader доказанно игнорирует, остановка может не понадобиться; это показывает matrix, а не интуиция.'),
paragraph('Не нужно обещать универсальный ручной рубильник. В одних системах owner может отключить writer через release, в других — через конфигурацию, очередь или права. Статья не выбирает механизм. Её правило уже уже: если в compatibility test нет зелёной пары для активного reader, producer не должен увеличивать число спорных record. Сначала останавливают создание нового несовместимого значения, затем решают, можно ли безопасно восстановить reader или нужен отдельный перевод.'),
dataTable(
'Выбор rollback-safe действия',
['Условие', 'Что можно откатить', 'Что сохраняем', 'Чего не делаем'],
[
['новый optional key, v1 reader его игнорирует', 'writer можно остановить; reader оставляют tolerant', 'v2 sample и matrix', 'не удаляем key из уже записанных record без причины'],
['reader ошибочно сузил <code>emailDigest</code>', 'откатываем reader contract или возвращаем legacy value', 'sample с <code>daily</code> и verdict теста', 'не переписываем <code>daily</code> в другое значение массово'],
['writer сменил semantic existing value', 'сначала останавливаем producer', 'old/new meaning, record ids, owner decision', 'не называем простой code rollback восстановлением semantics'],
['value неверного type', 'блокируем путь writer и чинить validator', 'ошибочный sample и error path', 'не заменяем value fallback-строкой без правила'],
],
),
heading('Rollback кода не всегда откатывает значение'),
paragraph('Это самая опасная часть разборов. Если v2 writer добавил independent optional key, старый reader может продолжить читать core, а v2 reader — понимать уже появившийся key. Code rollback в такой ситуации обычно не должен стирать новые record. Но если writer использовал <code>weekly</code> в новом смысле, у уже записанного value нет метки, которая вернёт старую трактовку. Вернуть бинарник назад недостаточно: старый reader прочитает ту же строку и решит, что она означает по-старому. Здесь требуется отдельный, владеемый переход, а не скрытый cleanup.'),
paragraph('Эта разница объясняет, почему evidence собирают раньше action. Сначала подтверждаем writer version, expected contract и samples. Затем выбираем rollback-safe маршрут: вернуть reader capability, остановить producer или подготовить новый field с явной семантикой. Важно не смешивать отмену кода с отменой данных. Реальное хранилище может иметь транзакции, snapshots, реплики или свои retention policy, но их нельзя приписывать нейтральной fixture.'),
heading('Возвращаем дефект в compatibility test'),
paragraph('После локализации случая он должен стать sample. Для type break добавляем record с <code>timezone: 3</code> и ожидаем rejection. Для presence break сохраняем v1 record без key и ожидаем <code>absent</code>. Для narrowing сохраняем legacy <code>daily</code> и ожидаем, что proposed reader будет отклонён matrix. Для semantic break сохраняем semantic marker contract и ожидаем rejection, пока owner не введёт отдельное поле или не опишет контролируемый переход. Иначе следующий релиз снова увидит только «странный старый record». '),
codeBlock(fixtureCode),
paragraph('В fixture уже есть тринадцать assertions. Они проверяют не реальную доставку, а условия учебного договора: v1/v2 reads, additive writer, absent versus explicit null, type rejection, order independence, narrowing, semantic and presence break. Если добавляется новая гарантия, её нельзя оставить в prose: нужен отдельный assertion. Это простая дисциплина для 2021 года — не утверждать, что reader «стал устойчивым», пока не видно, на каких input он обязан остановиться.'),
heading('Маршрут: симптом → причина → проверка → действие'),
orderedList([
'Зафиксировать record id, writer label, reader label, field names, error path и state спорного key. Не выводить в общий лог весь profile.',
'Проверить, это type break, absence/null, narrowing или semantic break. Одинаковый JavaScript type не отменяет semantic разрыв.',
'Сверить активную пару с compatibility matrix. Если пары нет или она красная, остановить producer, который продолжает создавать спорный value.',
'Для additive changes вернуть reader способность понимать old/new record. Для type или narrowing исправить contract и validator, а не подставлять default.',
'Если старое значение получило новый смысл, считать code rollback недостаточным: сохранить evidence, назначить owner и проектировать отдельный transition.',
'Добавить sample и ожидаемый verdict в fixture. После этого повторить только локальную matrix, затем выполнить integration checks выбранного storage отдельно.',
]),
heading('Границы разбора и источники'),
paragraph('RFC 8259 нужен здесь как граница JSON syntax и порядка object members. JSON Schema draft 2019-09 полезен для разговоров о structural validation, но не принимает за команду business decision о meaning поля. Apache Avro 1.10.2 показывает, что reader/writer resolution бывает частью конкретного формата; это не делает любое JSON-хранилище совместимым без matrix. Эти источники существовали к августу 2021 года и не используются для неподтверждённых claims о конкретном сервисе.'),
paragraph('Пакет не выполняет rollback, не останавливает настоящий producer и не открывает storage. Нет реальных record, production-логов, SLA, метрик, схемы доступа, browser, CI или deployment. Следующий шаг — повторить этот маршрут на одном безопасно обезличенном sample выбранной системы, указать фактический owner и добавить форматно-зависимый integration test. До такой проверки безопаснее остановить изменение, чем превратить неясный contract в новые необратимые записи.'),
],
sources,
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
.map(({ proseLength, ...revision }) => revision);
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else if (process.argv.includes('--verify-fixture')) {
process.stdout.write(JSON.stringify(runStorageContractFixture(), null, 2) + '\n');
}