The order-faithful primary-key core of the initial snapshot (C2, design Ch3/Ch4).
A chunk pages by ORDER BY pk in MySQL while the cursor-gate classifies streamed
changes by k ≤ cursor in Elixir. For that pairing to be correct — no silently
mis-classified change (delivered vs suppressed → gap/dup) — the PK's Elixir term-order
MUST provably match MySQL's ORDER BY. This module enforces that with a positive type
allowlist and supplies the two operations the coordinator needs on PK values:
canonical/2— the equality form for the sink ledger. Canonicalization happens in Elixir (reconciliation-equality), NOT MySQL collation. The text-protocol string form of a PK (a chunk read) and the binlog-decoded integer/binary form (a streamed change) canonicalize to the SAME term, so the ledger reconciles a chunk row against a streamed change to the same key.compare/2— the ordering for the cursor-gate (k ≤ cursor). Over the allowlist it is exactly MySQLORDER BY: integers order numerically,BINARY/VARBINARYorder byte-wise (shorter prefix first), and composites order lexicographically as tuples (matching MySQL row-value ORDER BY).
The positive allowlist (Ch3.2 / Ch4)
ACCEPT only the types whose Elixir order provably matches MySQL ORDER BY:
- signed/unsigned integer —
TINYINT/SMALLINT/MEDIUMINT/INT/BIGINT BINARY/VARBINARY(byte-ordered)- composites of the above (tuple compare)
REFUSE everything else with :snapshot_pk_unsupported_type:
- the collation-ordered STRING family —
CHAR/VARCHAR/TEXT(both a_binand a_cicollation reportdata_typechar/varchar, so collation cannot distinguish them; the whole family is refused). A collation order Elixir cannot reproduce would let the cursor-gate mis-classify a change. DECIMAL/DOUBLE/FLOAT/DATE/DATETIME/temporal and any other type — their Elixir term-order diverges from MySQL.
BIGINT UNSIGNED across 2^63 (Ch3.1)
An unsigned 64-bit PK spans [0, 2^64). A signed-wrap decoder reads the high half as
negative (2^63 → -2^63, 2^64-1 → -1), so it would order 2^63… before 0… and the
cursor-gate would mis-classify a high-half key. canonical/2 decodes to the TRUE unsigned
value: a text form parses directly, and a raw integer that arrived signed-wrapped (a
negative for an unsigned column) is normalized back into [0, 2^width).
No PK → fail closed
introspect/2 reads information_schema for the PRIMARY KEY. A table with no PK accepts
a UNIQUE key fallback ONLY IF every column of that key is NOT NULL — a nullable
unique-key column admits NULL-keyed rows that WHERE k > cursor never selects (a silent
gap, Ch3.3), so such a key is refused :snapshot_table_no_primary_key. A table with
neither is refused the same way.
Rule 1
No PK value is ever logged or telemetered here. Errors are value-free atoms. The
information_schema queries embed only the schema/table (structural identity — the same
fields telemetry may carry), never a row value, and are escaped as SQL string literals.
Summary
Types
A canonical PK: a single value (single-column PK) or a tuple (composite PK).
The introspected key: the ordered PK columns and their order-faithful types.
An order-faithful PK column type atom (integer family, :binary, :varbinary).
Functions
Builds the canonical equality form of a PK from pk_types and the raw column values
(raw_values, one per PK column in order).
Compares two canonical PK values (from canonical/2): :lt, :eq, or :gt.
Introspects the PRIMARY KEY (or a NOT-NULL-complete UNIQUE key fallback) of
{schema, table} over a Capstan.Query handle.
The pure key resolution over the two information_schema resultsets — the RED-capable
seam introspect/2 delegates to (so the PK / unique-key fallback / type-refusal logic is
provable without a live server).
Classifies a single column's (DATA_TYPE, COLUMN_TYPE) to an order-faithful pk_type(),
or refuses :snapshot_pk_unsupported_type.
Types
A canonical PK: a single value (single-column PK) or a tuple (composite PK).
The introspected key: the ordered PK columns and their order-faithful types.
@type pk_type() ::
:tinyint
| :tinyint_unsigned
| :smallint
| :smallint_unsigned
| :mediumint
| :mediumint_unsigned
| :int
| :int_unsigned
| :bigint
| :bigint_unsigned
| :binary
| :varbinary
An order-faithful PK column type atom (integer family, :binary, :varbinary).
Functions
@spec canonical([pk_type()], [binary() | integer()]) :: canonical_pk()
Builds the canonical equality form of a PK from pk_types and the raw column values
(raw_values, one per PK column in order).
A raw value is either a text-protocol string (a chunk read) or a binlog-decoded term (a
streamed change); both forms of the SAME PK canonicalize equal. Integers normalize to
their true value (an unsigned column's signed-wrapped negative is unwrapped into
[0, 2^width)); binaries pass through their bytes. A single-column PK returns a bare value;
a composite returns a tuple in column order.
@spec compare(canonical_pk(), canonical_pk()) :: :lt | :eq | :gt
Compares two canonical PK values (from canonical/2): :lt, :eq, or :gt.
Over the allowlist this is exactly MySQL ORDER BY: Erlang term order coincides with MySQL
numeric order for integers, byte-wise order for BINARY/VARBINARY, and — for same-arity
composites — lexicographic tuple order for row-value ORDER BY. Both arguments must be
canonical forms; the caller compares like against like (a table's PK shape is fixed).
@spec introspect( Capstan.Query.t(), {String.t(), String.t()} ) :: {:ok, key()} | {:error, atom()}
Introspects the PRIMARY KEY (or a NOT-NULL-complete UNIQUE key fallback) of
{schema, table} over a Capstan.Query handle.
Returns {:ok, %{pk_columns: [...], pk_types: [...]}} for an order-faithful key, or a
value-free {:error, reason}: :snapshot_pk_unsupported_type when a key column's type is
outside the allowlist, :snapshot_table_no_primary_key when there is no PK and no
NOT-NULL-complete unique key. A transport/query fault propagates its value-free reason
from Capstan.Query.query/2.
The pure key resolution over the two information_schema resultsets — the RED-capable
seam introspect/2 delegates to (so the PK / unique-key fallback / type-refusal logic is
provable without a live server).
index_rows—STATISTICSrows[INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, NON_UNIQUE]column_rows—COLUMNSrows[COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE]
(all columns are NOT NULL in information_schema, so no cell is nil). Returns the same
{:ok, key()} | {:error, reason} contract as introspect/2.
@spec resolve_pk_type(binary(), binary()) :: {:ok, pk_type()} | {:error, :snapshot_pk_unsupported_type}
Classifies a single column's (DATA_TYPE, COLUMN_TYPE) to an order-faithful pk_type(),
or refuses :snapshot_pk_unsupported_type.
Signedness rides COLUMN_TYPE (which carries the unsigned attribute); DATA_TYPE alone
cannot distinguish it. The whole string family (char/varchar/text/enum/…) and every
non-integer, non-binary type are refused (their Elixir order diverges from MySQL).