Раздел 10 · Databases

Своя СУБД на Effect · урок 11: бинарный протокол и сервер

senior~140 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Своя СУБД на 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.