Своя СУБД на Effect · урок 11: бинарный протокол и сервер
Кому это. За десять уроков мы построили целый движок: страницы на диске, буферный пул, кучу, B+Tree, парсер SQL, планировщик и исполнитель, журнал и транзакции, MVCC, блокировки на транзакционной памяти, vacuum. Но говорить с движком умеет только встроенный REPL, в том же процессе. Снаружи к нашей СУБД не подключиться.
Зачем. Это capstone воркшопа: выставляем движок в сеть. Поднимем TCP-сервер, придумаем своё кадрирование сообщений, реализуем simple query protocol в духе Postgres, напишем серверную сессию (держит транзакцию соединения) и клиент-библиотеку. В конце клиент откроет сокет, пошлёт SELECT, получит типизированные строки, а BEGIN/COMMIT заработают по сети. Поскольку байтовый контракт у всех языков воркшопа общий, клиент на Effect.ts сможет поговорить с сервером на Go или Rust.
Зачем именно Effect. Тут Effect раскрывает последнюю сторону, которой раньше не было: сокеты Effect вместо node:net. Сокет и сервер становятся Effect-значениями с Scope-управлением: цикл приёма соединений форкнут в Scope сервера (Effect.forkScoped), при закрытии Scope он гасится; чтение из сокета это поток Socket.toStream(socket), запись это socket.writer. Каждое соединение живёт в собственном Effect.scoped, и Effect.ensuring(session.close) гарантирует откат транзакции при любом исходе: успех, ошибка, обрыв соединения, отмена. Это structured concurrency, которой мы пользовались внутри одного процесса (Scope журнала, каталога, пула), теперь распространяется на сеть. И клиент устроен в той же логике: соединение это Effect<Connection, _, Scope>, выход из Scope закрывает сокет. Никаких ручных socket.destroy() в catch-блоках.
Решение по библиотеке. Можно было взять голый node:net и обернуть колбэки в Effect.async, как делают в эталонной TS-реализации. Но это значило бы переизобрести руками то, что уже лежит в effect/socket: управление жизненным циклом сокета, преобразование чанков в стрим, обработку обрыва. На Effect-стороне есть готовая обёртка, которая уже делает это правильно (с финалайзерами, отменой, типизированными ошибками SocketError). Поэтому осознанный выбор: сокеты из ядра effect плюс @effect/platform-node как конкретный рантайм. Это значит одну новую зависимость и Socket.toStream/socket.writer вместо event-emitter-стиля, но взамен сетевой слой становится таким же управляемым ресурсом, как и всё остальное в нашем движке.
Важно про объём. Этот урок целиком аддитивный: мы пишем новый пакет src/wire/, ничего в движке не трогая. Шесть файлов: кадрирование, кодеки сообщений (оба чистые, как Page и Tuple), сессия (Effect, потому что зовёт db.execute), сервер и клиент (Effect плюс сокеты из effect/socket), barrel-экспорт. Сервер опирается на готовый движок (db.execute из урока 6, db.begin из урока 9) и его tagged-ошибки. Граница Effect та же, что и десять уроков подряд: чистый кодек байт без Effect, ресурсная и сбойная часть через Effect.
Где мы в движке
Последний слой, он же вершина стека:
┌────────────────────────────────────────────────┐
│ Бинарный протокол + TCP-сервер │ урок 11 ← мы здесь
├────────────────────────────────────────────────┤
│ Исполнитель (Volcano) + EXPLAIN │ урок 6
│ Планировщик, binder, каталог │ урок 6
│ Парсер SQL │ урок 5
├────────────────────────────────────────────────┤
│ Vacuum (сборка мёртвых версий) │ урок 10
│ Блокировки (2PL), конкуренция │ урок 9
│ MVCC (видимость, версии кортежа) │ урок 8
│ Менеджер транзакций, WAL, recovery │ уроки 7, 8
├────────────────────────────────────────────────┤
│ B+Tree · Куча · Буферный пул · DiskManager │ уроки 1-4
└────────────────────────────────────────────────┘
Протокол не лезет внутрь движка: он сидит сверху, переводит байты сети в Effect-вызовы Database и обратно. Каждое соединение получает свою сессию и свою транзакцию, а изоляцию между соединениями уже обеспечивают снимки из урока 9.
Идея протокола
Простой протокол запросов это диалог из сообщений с типами. Все числа в нём big-endian, сетевой порядок байт. Это сознательный контраст с диском: на странице из урока 1 мы писали little-endian (родной для процессора), а на проводе пишем big-endian (традиция сетевых протоколов, как у Postgres). Один и тот же u32, разный порядок байт, разные методы Buffer.
Диалог строго запрос-ответ. Клиент шлёт сообщение Query с SQL-строкой. Сервер отвечает последовательностью: для SELECT это RowDescription (имена и типы колонок), потом DataRow на каждую строку, потом CommandComplete с тегом (SELECT 3). Любую последовательность сервер закрывает сообщением ReadyForQuery со статусом транзакции (I вне транзакции, T внутри, E транзакция упала). По нему клиент понимает, что ответ дочитан и можно слать следующий запрос. Ошибка это ErrorResponse с кодом и текстом, а потом всё равно ReadyForQuery.
Значения в DataRow идут текстом: int это десятичная строка, bool это t/f, text как есть, NULL это маркер длины минус один. Клиент разбирает текст обратно в типизированное значение по коду типа из RowDescription. Это упрощение Postgres (там бывает и бинарный формат), но его достаточно для полного цикла и оно избавляет от кодеков чисел на проводе.
Кадрирование
Начнём снизу: с разбиения потока на сообщения. Каждый кадр это байт типа сообщения (ASCII), затем u32 длины (big-endian, включает свои 4 байта, но не байт типа), затем payload. Заведи src/wire/framing.ts. Чистый код, без Effect: кодек байт, как Page и Tuple. Ни ресурсов, ни сбоев у него нет, поэтому Effect ничего не добавит.
// src/wire/framing.ts
/** Один разобранный кадр: тип сообщения и его payload. */
export interface Frame {
readonly type: number;
readonly payload: Buffer;
}
/** Размер заголовка кадра: байт типа плюс u32 длины. */
const FRAME_HEADER_SIZE = 5;
/** Собрать кадр: байт типа, длина (4 плюс payload), payload. */
export function encodeFrame(type: number, payload: Buffer): Buffer {
const frame = Buffer.alloc(FRAME_HEADER_SIZE + payload.length);
frame.writeUInt8(type, 0);
frame.writeUInt32BE(4 + payload.length, 1);
payload.copy(frame, FRAME_HEADER_SIZE);
return frame;
}
export class FrameReader {
private buffer: Buffer = Buffer.alloc(0);
/** Добавить чанк и вернуть все кадры, собравшиеся целиком. */
push(chunk: Buffer): Frame[] {
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
const frames: Frame[] = [];
while (this.buffer.length >= FRAME_HEADER_SIZE) {
const length = this.buffer.readUInt32BE(1); // включает 4 байта длины
const total = 1 + length; // плюс байт типа
if (this.buffer.length < total) {
break; // кадр ещё не дочитан
}
const type = this.buffer.readUInt8(0);
const payload = this.buffer.subarray(FRAME_HEADER_SIZE, total);
frames.push({ type, payload });
this.buffer = this.buffer.subarray(total);
}
return frames;
}
}
FrameReader это самая важная часть и одновременно та, про которую легко забыть. TCP даёт поток байт, а не сообщения: один чанк может принести половину кадра, полтора кадра или три кадра сразу. push копит байты в буфере и отрезает ровно те кадры, что собрались целиком, оставляя хвост на потом. Цикл читает длину, проверяет, дочитан ли кадр, и если да, вырезает его. Без этого приёмник рано или поздно прочитает мусор: на медленной сети или большом ответе фрагментация неизбежна.
Заметь, что FrameReader мутабельный (накопительный буфер в поле). Это локальная мутация внутри одного файбера: каждое соединение получит свой FrameReader, и обращаться к нему будет последовательно из потока чтения (через Effect.forEach мы передадим кадры в обработчик строго по одному). Если бы протокол был многопоточным, FrameReader пришлось бы запереть TRef, но Stream.runForEach гарантирует ровно-один-за-раз обход чанков.
Сообщения
Теперь кодеки payload-ов. Они тоже чистые, без Effect: сообщения сериализуются и разбираются по байтам, как кадр. Заведи src/wire/messages.ts. Сначала типы сообщений и таблицы кодов.
// src/wire/messages.ts
import type { ColumnType } from '~/schema.ts';
import type { Value } from '~/tuple.ts';
/** Байт типа сообщения (ASCII). */
export const MessageType = {
query: 0x51, // 'Q'
terminate: 0x58, // 'X'
rowDescription: 0x54, // 'T'
dataRow: 0x44, // 'D'
commandComplete: 0x43, // 'C'
errorResponse: 0x45, // 'E'
readyForQuery: 0x5a, // 'Z'
} as const;
/** Код типа колонки в RowDescription. */
const TYPE_CODE: Record<ColumnType, number> = { int: 0, text: 1, bool: 2 };
const CODE_TYPE: readonly ColumnType[] = ['int', 'text', 'bool'];
/** Статус транзакции в ReadyForQuery. */
export type TxnStatus = 'I' | 'T' | 'E';
/** Поле результата: имя колонки и её тип. */
export interface Field {
readonly name: string;
readonly type: ColumnType;
}
/** Сообщение клиент в сервер. */
export type ClientMessage =
| { readonly kind: 'query'; readonly sql: string }
| { readonly kind: 'terminate' };
/** Сообщение сервер в клиент (так его строит сервер: DataRow несёт типизированные Value). */
export type ServerMessage =
| { readonly kind: 'rowDescription'; readonly fields: readonly Field[] }
| { readonly kind: 'dataRow'; readonly values: readonly Value[] }
| { readonly kind: 'commandComplete'; readonly tag: string }
| { readonly kind: 'errorResponse'; readonly code: string; readonly message: string }
| { readonly kind: 'readyForQuery'; readonly status: TxnStatus };
/** Сырые ячейки DataRow на проводе: текст или NULL. Тип отдаёт RowDescription. */
export type RawRow = readonly (string | null)[];
/**
* Серверное сообщение после разбора на клиенте. Отличается от ServerMessage только
* DataRow: значения приходят сырым текстом (RawRow), а не Value, и клиент парсит их по
* типам из RowDescription (decodeValueText). Так тип честно отражает, что байты ещё текст.
*/
export type DecodedServerMessage =
| Exclude<ServerMessage, { readonly kind: 'dataRow' }>
| { readonly kind: 'dataRow'; readonly values: RawRow };
Тонкий, но важный тип это DecodedServerMessage. Сервер строит DataRow из типизированных Value (он знает типы), а клиент получает сырой текст и ещё не разобрал его: типы он узнает только из пришедшего раньше RowDescription. Поэтому разобранный DataRow несёт RawRow (массив строк или null), и тип это честно отражает: байты на проводе это текст, пока клиент не применит decodeValueText. Дальше диспетчеры: тип кадра и payload по сообщению, и обратно.
// src/wire/messages.ts (продолжение: диспетчеры серверных сообщений)
/** Байт типа для серверного сообщения. */
export function serverMessageType(message: ServerMessage): number {
switch (message.kind) {
case 'rowDescription':
return MessageType.rowDescription;
case 'dataRow':
return MessageType.dataRow;
case 'commandComplete':
return MessageType.commandComplete;
case 'errorResponse':
return MessageType.errorResponse;
case 'readyForQuery':
return MessageType.readyForQuery;
}
}
/** Закодировать payload серверного сообщения. */
export function encodeServerPayload(message: ServerMessage): Buffer {
switch (message.kind) {
case 'rowDescription':
return encodeRowDescription(message.fields);
case 'dataRow':
return encodeDataRow(message.values);
case 'commandComplete':
return Buffer.from(message.tag, 'utf8');
case 'errorResponse':
return encodeErrorResponse(message.code, message.message);
case 'readyForQuery':
return Buffer.from([message.status.codePointAt(0) ?? 0]);
}
}
/** Разобрать серверное сообщение из типа кадра и payload (DataRow остаётся сырым текстом). */
export function decodeServerMessage(type: number, payload: Buffer): DecodedServerMessage {
switch (type) {
case MessageType.rowDescription:
return { kind: 'rowDescription', fields: decodeRowDescription(payload) };
case MessageType.dataRow:
return { kind: 'dataRow', values: decodeDataRow(payload) };
case MessageType.commandComplete:
return { kind: 'commandComplete', tag: payload.toString('utf8') };
case MessageType.errorResponse:
return { kind: 'errorResponse', ...decodeErrorResponse(payload) };
case MessageType.readyForQuery:
return { kind: 'readyForQuery', status: statusFromByte(payload) };
default:
throw new RangeError(`неизвестный тип серверного сообщения 0x${type.toString(16)}`);
}
}
Клиентские сообщения проще: их всего два. Query несёт SQL-строку с завершающим нулевым байтом (как в Postgres), Terminate пуст.
// src/wire/messages.ts (продолжение: клиентские сообщения)
/** Байт типа для клиентского сообщения. */
export function clientMessageType(message: ClientMessage): number {
return message.kind === 'query' ? MessageType.query : MessageType.terminate;
}
/** Закодировать payload клиентского сообщения. */
export function encodeClientPayload(message: ClientMessage): Buffer {
if (message.kind === 'terminate') {
return Buffer.alloc(0);
}
const sql = Buffer.from(message.sql, 'utf8');
return Buffer.concat([sql, Buffer.from([0])]); // SQL плюс завершающий NUL
}
/** Разобрать клиентское сообщение из типа кадра и payload. */
export function decodeClientMessage(type: number, payload: Buffer): ClientMessage {
switch (type) {
case MessageType.query: {
const end = payload.length > 0 && payload.at(-1) === 0 ? -1 : undefined;
return { kind: 'query', sql: payload.subarray(0, end).toString('utf8') };
}
case MessageType.terminate:
return { kind: 'terminate' };
default:
throw new RangeError(`неизвестный тип клиентского сообщения 0x${type.toString(16)}`);
}
}
/** Текстовое представление значения для DataRow (null отдельным маркером длины). */
export function encodeValueText(value: Value): Buffer | null {
if (value === null) {
return null;
}
if (typeof value === 'bigint') {
return Buffer.from(value.toString(10), 'utf8');
}
if (typeof value === 'boolean') {
return Buffer.from(value ? 't' : 'f', 'utf8');
}
return Buffer.from(value, 'utf8');
}
/** Разобрать текстовое значение колонки по её типу. */
export function decodeValueText(type: ColumnType, text: string): Value {
switch (type) {
case 'int':
return BigInt(text);
case 'bool':
return text === 't';
case 'text':
return text;
}
}
Остались помощники для составных сообщений. RowDescription это число полей и на каждое имя (длина плюс байты) и код типа. DataRow это число колонок и на каждую длина (int32, минус один значит NULL) плюс байты. ErrorResponse это код и текст. Все читают и пишут симметрично, поэтому привожу их вместе.
// src/wire/messages.ts (продолжение: кодеки составных payload-ов)
function encodeRowDescription(fields: readonly Field[]): Buffer {
const head = Buffer.alloc(2);
head.writeUInt16BE(fields.length, 0);
const chunks: Buffer[] = [head];
for (const field of fields) {
const name = Buffer.from(field.name, 'utf8');
const nameLength = Buffer.alloc(2);
nameLength.writeUInt16BE(name.length, 0);
chunks.push(nameLength, name, Buffer.from([TYPE_CODE[field.type]]));
}
return Buffer.concat(chunks);
}
function decodeRowDescription(payload: Buffer): Field[] {
const count = payload.readUInt16BE(0);
const fields: Field[] = [];
let cursor = 2;
for (let index = 0; index < count; index++) {
const nameLength = payload.readUInt16BE(cursor);
cursor += 2;
const name = payload.toString('utf8', cursor, cursor + nameLength);
cursor += nameLength;
const code = payload.readUInt8(cursor);
cursor += 1;
const type = CODE_TYPE[code];
if (type === undefined) {
throw new RangeError(`неизвестный код типа колонки ${code}`);
}
fields.push({ name, type });
}
return fields;
}
function encodeDataRow(values: readonly Value[]): Buffer {
const head = Buffer.alloc(2);
head.writeUInt16BE(values.length, 0);
const chunks: Buffer[] = [head];
for (const value of values) {
const bytes = encodeValueText(value);
const length = Buffer.alloc(4);
if (bytes === null) {
length.writeInt32BE(-1, 0); // NULL это длина минус один, без байт данных
chunks.push(length);
} else {
length.writeInt32BE(bytes.length, 0);
chunks.push(length, bytes);
}
}
return Buffer.concat(chunks);
}
function decodeDataRow(payload: Buffer): RawRow {
const count = payload.readUInt16BE(0);
const values: (string | null)[] = [];
let cursor = 2;
for (let index = 0; index < count; index++) {
const length = payload.readInt32BE(cursor);
cursor += 4;
if (length === -1) {
values.push(null);
continue;
}
values.push(payload.toString('utf8', cursor, cursor + length));
cursor += length;
}
return values;
}
function encodeErrorResponse(code: string, message: string): Buffer {
const codeBytes = Buffer.from(code, 'ascii');
const head = Buffer.alloc(2);
head.writeUInt16BE(codeBytes.length, 0);
return Buffer.concat([head, codeBytes, Buffer.from(message, 'utf8')]);
}
function decodeErrorResponse(payload: Buffer): { code: string; message: string } {
const codeLength = payload.readUInt16BE(0);
const code = payload.toString('ascii', 2, 2 + codeLength);
const message = payload.toString('utf8', 2 + codeLength);
return { code, message };
}
function statusFromByte(payload: Buffer): TxnStatus {
const status = String.fromCodePoint(payload.readUInt8(0));
if (status !== 'I' && status !== 'T' && status !== 'E') {
throw new RangeError(`неизвестный статус транзакции "${status}"`);
}
return status;
}
Много кода, но он весь однообразный: записал число big-endian, записал байты, на чтении прошёл курсором ровно в том же порядке. Главное правило кодека: читай в точности так, как писал, поле за полем. И ни одного Effect: вход чистый, выход чистый, граница протокола проходит выше.
Сессия соединения
Кадры и сообщения готовы. Теперь логика: что сервер делает с Query. Сессия превращает SQL-строку в последовательность серверных сообщений и держит состояние транзакции этого соединения. Заведи src/wire/session.ts. Тут впервые появляется Effect: db.execute это Effect<ExecResult, DbError>, и сессия зовёт его внутри.
// src/wire/session.ts
import { Effect, Result } from 'effect';
import type { Database, DbError, ExecResult, Transaction } from '~/engine/database.ts';
import { parseCommand } from '~/sql/index.ts';
import type { ServerMessage, TxnStatus } from '~/wire/messages.ts';
/** Хэндл сессии: выполнить SQL и завершить (откатить открытую транзакцию). */
export interface Session {
readonly run: (sql: string) => Effect.Effect<ServerMessage[]>;
/** Откатить открытую транзакцию (вызывается при Terminate и закрытии сокета). */
readonly close: Effect.Effect<void>;
}
/** Завести сессию над соединением с базой. Мутабельное состояние живёт в замыкании. */
export const makeSession = (db: Database): Session => {
let handle: Transaction | null = null;
let failed = false;
const status = (): TxnStatus => {
if (failed) {
return 'E';
}
return handle === null ? 'I' : 'T';
};
const respond = (messages: ServerMessage[]): ServerMessage[] => [
...messages,
{ kind: 'readyForQuery', status: status() },
];
Ключевые два поля: handle (открытая транзакция этого соединения, через db.begin из урока 9) и failed (статус упавшей транзакции). Оба мутабельные let в замыкании, как sessionXid в Database и dirtyRef в withPin: одно соединение это один файбер, гонок нет, оборачивать в Ref смысла нет. Дальше транзакционные команды:
// src/wire/session.ts (продолжение)
const runTxn = (op: 'begin' | 'commit' | 'rollback'): Effect.Effect<ServerMessage[], DbError> =>
Effect.gen(function* () {
switch (op) {
case 'begin': {
if (handle === null) {
handle = yield* db.begin;
failed = false;
}
return respond([{ kind: 'commandComplete', tag: 'BEGIN' }]);
}
case 'commit': {
const tag = yield* finishTxn('commit');
return respond([{ kind: 'commandComplete', tag }]);
}
case 'rollback': {
yield* finishTxn('rollback');
return respond([{ kind: 'commandComplete', tag: 'ROLLBACK' }]);
}
}
});
/** Завершить транзакцию. COMMIT упавшей это откат с тегом ROLLBACK. */
const finishTxn = (op: 'commit' | 'rollback'): Effect.Effect<string, DbError> =>
Effect.gen(function* () {
if (handle === null) {
return op === 'commit' ? 'COMMIT' : 'ROLLBACK';
}
const current = handle;
handle = null;
const wasFailed = failed;
failed = false;
if (op === 'rollback' || wasFailed) {
yield* current.rollback;
return 'ROLLBACK';
}
yield* current.commit;
return 'COMMIT';
});
И сам execute: парсим, ловим ошибки через Effect.result (а не try/catch, как в TS-треке: parseCommand отдаёт Result, db.execute это Effect, и ошибки уже значения, забирать их через Result идиоматичнее):
// src/wire/session.ts (продолжение)
const execute = (sql: string): Effect.Effect<ServerMessage[], DbError> =>
Effect.gen(function* () {
const parsed = parseCommand(sql);
if (Result.isFailure(parsed)) {
return respond([errorOf(parsed.failure)]);
}
if (parsed.success.kind === 'txn') {
return yield* runTxn(parsed.success.op);
}
if (failed) {
return respond([
{ kind: 'errorResponse', code: '25P02', message: 'текущая транзакция прервана' },
]);
}
const outcome = yield* Effect.result(
handle === null ? db.execute(sql) : handle.execute(sql),
);
if (Result.isFailure(outcome)) {
if (handle !== null) {
failed = true; // транзакция упала: до ROLLBACK команды отвергаются
}
return respond([errorOf(outcome.failure)]);
}
return respond(messagesFor(outcome.success));
});
// ошибки commit/rollback (редкие, в тестах не возникают) тоже становятся ErrorResponse
const run = (sql: string): Effect.Effect<ServerMessage[]> =>
execute(sql).pipe(Effect.catch((error) => Effect.succeed(respond([errorOf(error)]))));
const close: Effect.Effect<void> = Effect.suspend(() => {
if (handle === null) {
return Effect.void;
}
const current = handle;
handle = null;
failed = false;
return current.rollback.pipe(Effect.catch(() => Effect.void));
});
return { run, close };
};
Тут несколько ключевых решений Effect-порта.
run тотален, его канал ошибок never. Все возможные ошибки внутри (парсера, исполнителя, commit/rollback) ловятся либо Effect.result (и становятся ErrorResponse), либо хвостовым Effect.catch (на редкий случай ошибки в самом commit/rollback-Effect). Это удобно для сервера: он зовёт session.run(sql) и не обязан обрабатывать ошибки, ответ всегда успешен и всегда заканчивается ReadyForQuery. На стороне типов это значит, что в handleConnection (сервер) канал ошибок чистый, и обработка идёт только по SocketError (обрыв соединения).
close идемпотентен и глотает ошибку отката. Effect.suspend оборачивает чтение handle в Effect, чтобы каждый запуск брал текущее значение замыкания. pipe(Effect.catch(() => Effect.void)) гасит ошибку отката: если транзакция уже завершена или откат невозможен, соединение всё равно рвём, не оставляя ресурсов. Эта тотальность важна для Effect.ensuring(session.close) на сервере: финалайзеры должны быть безопасными к запуску.
Каждое соединение это своя транзакция. Транзакционные команды берут собственный хэндл через db.begin (это Effect<Transaction>), а не глобальный sessionXid движка, иначе все соединения делили бы одну транзакцию. Вне транзакции DML идёт в autocommit, SELECT берёт лёгкий снимок. Обработку ошибок мы уже видели в Database: упавшая транзакция переходит в статус failed, и до ROLLBACK любая команда отвергается кодом 25P02, ровно как в Postgres.
Сессия дополняется двумя свободными функциями: разворот результата движка в сообщения и сопоставление тега ошибки с кодом.
// src/wire/session.ts (продолжение)
/** Развернуть ExecResult в серверные сообщения (без ReadyForQuery). */
function messagesFor(result: ExecResult): ServerMessage[] {
switch (result.kind) {
case 'select': {
const description: ServerMessage = { kind: 'rowDescription', fields: result.fields };
const rows: ServerMessage[] = result.rows.map((values) => ({ kind: 'dataRow', values }));
return [
description,
...rows,
{ kind: 'commandComplete', tag: `SELECT ${result.rows.length}` },
];
}
case 'command':
return [{ kind: 'commandComplete', tag: result.tag }];
case 'explain':
return [
{ kind: 'rowDescription', fields: [{ name: 'QUERY PLAN', type: 'text' }] },
{ kind: 'dataRow', values: [result.plan] },
{ kind: 'commandComplete', tag: 'EXPLAIN' },
];
}
}
function errorOf(error: DbError): ServerMessage {
return { kind: 'errorResponse', code: errorCode(error), message: messageOf(error) };
}
/** SQLSTATE-подобный код по тегу ошибки движка (для ErrorResponse). */
function errorCode(error: DbError): string {
switch (error._tag) {
case 'UniquenessError':
return '23505';
case 'SerializationError':
return '40001';
case 'DeadlockError':
return '40P01';
case 'LockWaitError':
return '55P03';
case 'SqlError':
return '42601';
case 'EngineError':
return '42000';
default:
return 'XX000';
}
}
function messageOf(error: DbError): string {
return error.message.length > 0 ? error.message : error._tag;
}
errorCode это та же таблица SQLSTATE, что и в TS-треке, но коммутирующая через error._tag, а не instanceof. Все наши ошибки это Data.TaggedError, у них есть literal-поле _tag, и switch по нему даёт исчерпывающую проверку (TS сужает тип в каждой ветке). Если завтра добавится новая Data.TaggedError, TS попросит обновить switch. Это безопаснее instanceof-цепочки, где порядок веток важен (подклассы должны идти раньше базы).
messagesFor это перевод результата движка на язык протокола: SELECT разворачивается в описание плюс строки плюс тег, EXPLAIN притворяется однострочным SELECT с колонкой QUERY PLAN, а команда (INSERT, CREATE) это тег.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.