Estado: ✅ Aceptada Fecha: 2026-05-26 Contexto que la motiva: bloque K2 — habilitar PRIMARY KEY (a, b, ...) y CREATE [UNIQUE] INDEX idx ON t (a, b, ...) sin reescribir el B+Tree de claves i64.

🧭 Contexto

Hasta VERSION 7 el motor asumía PK escalar única y exactamente un índice secundario por columna:

El backlog enterprise pide PK compuesta ((curso, alumno), (year, month, sku), etc.) y índices multi-columna para acelerar consultas con varios predicados AND. Sin esto, casos triviales como una tabla de asistencias (PRIMARY KEY (curso, alumno)) no se pueden expresar — el usuario tiene que falsear una PK sintética + UNIQUE, lo que rompe la equivalencia ANSI.

Las restricciones que pesan:

💡 Decisión

Tres cambios coordinados bajo VERSION 8, todos restringidos a claves compuestas all-INT NOT NULL:

1. Modelo de catálogo aditivo

pub struct TableMeta {
    pub primary_key: String,           // primera columna PK (caso histórico = única)
    pub primary_key_extra: Vec<String>,// K2: vacío para PK single, ≥ 1 para PK compuesta
    // ...
}

pub struct IndexMeta {
    pub column: String,                // primera columna del índice
    pub extra_columns: Vec<String>,    // K2: vacío para single-column
    // ...
}

Helpers pk_columns(), has_composite_pk(), is_pk_column(name), all_columns(), is_composite(). Las callsites legacy single-column siguen usando meta.primary_key y idx.column directos; las nuevas pasan por los helpers.

2. Fingerprint FNV-1a-64 como clave compuesta

// src/index.rs
pub fn encode_composite_key(columns: &[&Column], values: &[&Value]) -> DbResult<i64>;

La clave i64 entra directo al B+Tree como cualquier otra. Equality lookup es O(log N + bucket_size). Range scan sobre claves compuestas NO se soporta — el fingerprint no es order-preserving.

3. Formato VERSION 8 en disco

TableMeta:

[name][pk_count:u8][pk_col_name × pk_count][root_page:u32][col_count:u16][cols...]
[idx_count:u16] × { [name][column][root_page:u32][unique:u8][kind:u8]
                    [extra_cols_count:u8][extra_col_name × extra_cols_count] }

Cambios vs V7:

V7 se rechaza al abrir con [GBY-1003] UNSUPPORTED_FORMAT_VERSION y un mensaje que sugiere backup + dump + recreate. No hay auto-upgrade: la PK compuesta cambia la semántica de la clave del B+Tree para CUALQUIER tabla creada bajo la nueva forma, y propagar eso a una DB V7 in-place sería un punto de fallo silencioso.

4. Reglas operativas para claves compuestas

5. Índices compuestos: bucket layout

El índice compuesto vive como IndexKind::OrderedInt (no por su orden semántico — un fingerprint no tiene orden útil — sino por reutilizar el decoder existente de decode_ordered_bucket). Bucket = [count:u16] + count × [pk:i64]. La clave del B+Tree es el fingerprint i64 calculado por encode_composite_key. INTEGRITY CHECK recorre los buckets con el mismo decoder.

UNIQUE compuesto: el backfill detecta colisiones por fingerprint y emite [GBY-3003] UNIQUE_VIOLATED. Una colisión real de FNV-1a-64 (≠ duplicación) es astronómicamente improbable sobre tuplas de INT.

🔄 Alternativas consideradas

⚖️ Consecuencias

Positivas

Negativas / deuda

📎 Referencias