У схемы из прошлых уроков много работы: она разбирает, записывает, даёт тип. Но она ещё и описание данных, дерево, которое можно обойти и перевести во что угодно. Из одного описания выходит много производных, и все они меняются вместе со схемой. Сегодня три: договор для чужих, равенство и генератор для проверки свойств.
Копия для гильдий: JSON Schema
JSON Schema это язык договоров о JSON, который понимает почти любой инструмент на любом языке. Schema.toJsonSchemaDocument переводит схему на этот язык:
export const frameContract = Schema.toJsonSchemaDocument(Frame);
{
"type": "object",
"required": ["id", "route", "priority", "headers", "message"],
"id": { "type": "integer", "maximum": 9007199254740991, "exclusiveMinimum": 0 },
"priority": { "type": "string", "enum": ["low", "normal", "urgent"] },
"route": {
"type": "array",
"prefixItems": [{ "type": "string" }, { "type": "string" }],
"maxItems": 2,
"minItems": 2
}
}
Это выборка из frameContract.schema, сам документ длиннее. Видно, что переехало: обязательные поля, целое больше нуля из FrameId, три слова priority, кортеж из двух элементов. relay в required нет: он optionalKey.
А теперь честно о том, что не переехало. У кода башни в JSON Schema написано просто "type": "string": проверка isPattern из урока 78 в 4.1 в документ не попадает. Свой фильтр «башня не шлёт кадр сама себе» не переедет никогда: это функция на TypeScript, и перевести её на язык JSON Schema нельзя. Бренды тоже остаются у нас, у ткачей их нет. Копия для гильдий слабее оригинала. Поэтому гильдия проверяет ею свои кадры до отправки, чтобы реже получать отказ, а принимаем мы всё равно своей схемой, а не их проверкой.
Договор с моделью
Тот же документ отдают языковой модели, когда просят ответ строго по форме: «ответь JSON по этой схеме». Модель это ещё одна гильдия, которая пишет нам кадры, и доверять ей стоит ровно так же, как Магистрали. Её ответ это строка с провода: разбираешь той же схемой, отказ объясняешь explain из урока 81 и, если хочешь, возвращаешь модели как подсказку для второй попытки. Схема получается договором в обе стороны: JSON Schema говорит модели, что прислать, а разбор проверяет, что прислали.
Копия для часовщиков: равенство
Два разбора одной строки дают два разных объекта. === сравнивает ссылки и ответит false. Сравнить по JSON.stringify? Он зависит от порядка ключей, а sentAt у нас DateTime.Utc, объект, а не строка. Нужно равенство по значению, которое знает форму кадра. Схема её знает:
export const sameFrame = Schema.toEquivalence(StampedFrame);
Равенство sameFrame спускается по полям: числа и строки сравнивает как есть, кортеж по элементам, headers по ключам, DateTime по моменту времени. Два разбора одной строки равны. Тот же кадр на миллисекунду позже не равен. В уроке 44 ты видел Equal.equals для классов Data; здесь то же, но для любой схемы, даже если её значения простые объекты.
Копия для проверки: генератор
Третья копия самая хитрая. Чтобы проверить, что договор честный, нужен не один кадр, а тысячи разных, в том числе таких, какие ты бы не придумал. Схема знает, из чего состоит кадр, значит может их и породить: число в пределах проверок, строку нужного вида, одно из трёх слов срочности.
Нестабильно. Модуль
effect/Arbitraryв 4.1 помечен@stability unstable: его имена и настройки могут поменяться в минорной версии. Это свой генератор Effect, а не библиотекаfast-check, хотя идея та же. Урок написан на 4.1.0.
Arbitrary.schema(S) строит генератор значений схемы S. Arbitrary.checkEffect(генератор, свойство) порождает значения и проверяет на каждом свойство, пока не найдёт контрпример или не кончатся попытки. Про проверку свойств подробно было в уроке 27, там законы проверял fast-check.
Свойство честного договора: записал на провод, прочитал обратно, вышло то же. Вот оно на подтверждении Ack, у которого номер Schema.Number:
const Ack = Schema.Struct({ kind: Schema.Literal('ack'), of: Schema.Number });
const AckLine = Schema.fromJsonString(Ack);
const same = Schema.toEquivalence(Ack);
const survives = (ack: typeof Ack.Type) =>
Schema.encodeEffect(AckLine)(ack).pipe(
Effect.flatMap(Schema.decodeEffect(AckLine)),
Effect.map((back) => same(back, ack)),
Effect.orElseSucceed(() => false),
);
const result = await Effect.runPromise(
Arbitrary.checkEffect(Arbitrary.schema(Ack), survives, { runs: 1000, seed: 3 }),
);
{
_tag: 'Falsified',
initialInput: { kind: 'ack', of: NaN },
shrunkInput: { kind: 'ack', of: NaN },
failure: { _tag: 'ReturnedFalse' },
runs: 239,
...
}
На двести тридцать девятом кадре договор соврал. Schema.Number честно пропускает NaN: это число в смысле TypeScript. Но JSON не умеет NaN, и JSON.stringify пишет вместо него null:
{"kind":"ack","of":null}
Expected number
at ["of"]
Кадр, который наша же схема пустила внутрь, наша же схема не может прочитать с провода. С другим seed генератор приносит Infinity: та же беда. Руками такой кадр никто бы не написал, а Магистраль однажды прислала бы. Поэтому номера в сообщениях кадра в эталоне не Schema.Number, а Schema.Int: целое число конечно, и JSON его переносит. С Schema.Int та же проверка отвечает { _tag: 'Passed', runs: 1000 }.
В тесте этапа свойство пишут короче, через @effect/vitest: it.effect.prop получает имя, схемы входов и свойство, а генератор строит из схем сам:
it.effect.prop('любой кадр по договору переживает провод', { frame: StampedFrame }, ({ frame }) =>
Effect.map(roundTrip(frame), (back) => sameFrame(back, frame)),
);
Генератор кадров, равенство и проверка, что кадр переживает провод, все три выведены из одной StampedFrame. Добавишь поле, и все три о нём узнают.
Где тут дыра
const weaversContract = {
type: 'object',
required: ['id', 'route', 'priority', 'message'],
properties: {
id: { type: 'number' },
priority: { enum: ['low', 'normal', 'urgent'] },
},
};
В прошлом месяце копия совпадала с договором. С тех пор у кадра появились обязательные headers, а номер стал целым больше нуля. Ткачи проверяют свои кадры по копии Ринцвальда, кадры проходят, а мы отказываем: headers: нет поля. Ткачи уверены, что виноваты мы, и по-своему правы: мы дали им неверный договор. Рукописная копия расходится с оригиналом молча, в день, когда кто-то правит схему и не помнит о копии.
Лечится тем, что копия выводится из схемы при каждой сборке. Править её руками нельзя, потому что руками её никто не пишет.
Магистраль, этап 8
Договор кадров получает три копии: JSON Schema для гильдий, равенство кадров и проверку, что кадр переживает провод. Главный тест этапа это свойство на тысячах кадров, которые генератор выводит из той же схемы.
Практика
Глубже в архиве
Схемы как договор с моделью в v3 разобраны в уроке «Schema для LLM» старого раздела, проверка свойств в уроке «Свойства и снимки» раздела про тестирование.
Дальше
Утром Рыдкулл кладёт на стол Казимира объявление Магистрали, ещё пахнущее краской.
Две версии одного договора ждут в следующем уроке.