Capstan.Binlog.Rows (Capstan v0.1.0)

Copy Markdown View Source

Decodes a full row image from the Capstan.Binlog.Decoder's raw tuple and the resolved %Capstan.Binlog.TableMap{}.

The decoder splits a ROWS event into {op, table_id, present_bitmap(s), raw} but leaves every value byte untouched. This module turns raw into decoded rows:

  • WRITE/DELETE carry one image per row; UPDATE carries a before/after image pair per row. A single event carries many rows (e.g. a multi-row INSERT or a multi-table UPDATE), so raw is walked until it is exhausted.
  • Each image starts with a per-row NULL bitmap over the columns present in the image (its width is driven by the columns-PRESENT bitmap, not the full column count — probe:339-351 assumes all columns present, which is wrong in general).
  • Each present, non-null column is cast via Capstan.Casting.Types.cast/4; a present-null column is recorded as nil and consumes no value bytes.

Rows are returned as column_name => value maps, the shape Capstan.Change consumes.

Fail closed, never silently wrong

The single biggest failure mode here is a plausible-but-wrong value, so the decode is built to fail closed at every point one could arise:

  • Wrong schema (ADR-0002). A table_id is resolved to a %TableMap{} by the owner; this module additionally asserts the event's table_id equals the map's, so a caller that pairs ta's rows with tb's map is refused rather than casting against the wrong columns.
  • Signedness. The per-column signedness bit is resolved from TLV type 1 and passed to the caster. A numeric column with no signedness bitmap fails closed rather than defaulting to signed.
  • Unsupported column (SET, JSON opaque, unknown type/meta). Any caster error aborts the whole decode; a partial or raw-passthrough row is never emitted.
  • Truncated image. If the raw bytes are too short to hold even a row's NULL bitmap, the decode returns {:error, {:truncated_row_image, _}} rather than raising — a clean halt the pipeline can dispatch (Event.parse/1's CRC check makes a truncation here a decode-desync signal, not silent corruption).

All error tuples carry only schema-level facts, never a column value (Rule 1).

Summary

Types

The decoded event: the semantic op plus its rows (UPDATE as before/after pairs).

A fail-closed refusal — an unsupported column, a schema mismatch, missing metadata, or a truncated image.

A decoded row: present columns keyed by name, present-null columns as nil.

Functions

Decodes a Capstan.Binlog.Decoder row tuple against its resolved %TableMap{}.

Types

decoded()

@type decoded() ::
  {:insert, [row()]} | {:delete, [row()]} | {:update, [{row(), row()}]}

The decoded event: the semantic op plus its rows (UPDATE as before/after pairs).

error()

@type error() ::
  {:unsupported_column_type, map()}
  | {:table_id_mismatch, map()}
  | {:missing_column_names, map()}
  | {:truncated_row_image, map()}

A fail-closed refusal — an unsupported column, a schema mismatch, missing metadata, or a truncated image.

row()

@type row() :: %{optional(String.t()) => term()}

A decoded row: present columns keyed by name, present-null columns as nil.

Functions

decode(arg, table_map)

@spec decode(Capstan.Binlog.Decoder.rows(), Capstan.Binlog.TableMap.t()) ::
  {:ok, decoded()} | {:error, error()}

Decodes a Capstan.Binlog.Decoder row tuple against its resolved %TableMap{}.

Returns {:ok, {:insert | :delete, [row]}}, {:ok, {:update, [{before, after}]}}, or {:error, reason} — an unsupported column, a schema mismatch, missing metadata, or a NULL-bitmap-truncated image all fail closed to an {:error, _} tuple (see the module doc). A value-image so short that a value runs off the end raises rather than returning a tuple: Capstan.Binlog.Event.parse/1 CRC-verifies the whole event before the decoder runs, so that is a decode-desync assertion on already-validated bytes, not an input-validation path.