Estado: ✅ Aceptada Fecha: 2026-05-29 Bloque: Y2 (primer sub-bloque post-Y) Bump on-disk: 17 → 18

🧭 Contexto

Y dejó pasar VARCHAR(n) y CHAR(n) como aliases puros de TEXT: el (n) se aceptaba sintácticamente pero no se persistía ni se enforcaba. Eso es suficiente para portar schemas sin editar, pero no para validar la calidad de los datos: una columna email VARCHAR(254) no protegía de inserts mucho más grandes.

Y2 cierra esa brecha. Es el sub-bloque más liviano de los que quedaron diferidos en Y porque no requiere cambios al Value enum ni a la serialización de filas — solo persiste un Option<u32> por columna en el catálogo y agrega un check en el encoder.

BLOB/BYTEA, DECIMAL exacto, ARRAY, range enforcement para SMALLINT/TINYINT, formato % en RAISE, etc. siguen diferidos a Y3+.

💡 Decisión

1. Persistir max_length: Option<u32> en Column

pub struct Column {
    pub name: String,
    pub column_type: ColumnType,
    pub not_null: bool,
    pub default: Option<DefaultLiteral>,
    pub references: Option<ForeignKeyMeta>,
    pub max_length: Option<u32>,  // Y2
}

2. Disk format

Nuevo flag bit:

const COLUMN_FLAG_HAS_MAX_LENGTH: u8 = 0x08;

Cuando está prendido, después del bloque opcional de FK (que en sí mismo es variable-length) se escriben 4 bytes LE con el u32. El decoder lo lee condicionalmente al flag.

Columnas escritas con el flag apagado (= sin max_length, lo normal) son byte-idénticas a V17. La adición es 100% additive en wire format.

3. Bump 17 → 18

Necesario aunque la adición sea additive: un binario V17 leyendo un schema V18 con max_length no sabría que después del FK puede haber 4 bytes extra y leería el idx_count desde el offset equivocado. Bump obligatorio. V17 → rechazado con [GBY-1003] (export/import manual).

4. Helper extract_length_param

Función pura en sql.rs:

pub(crate) fn extract_length_param(type_name: &str) -> Option<u32>

Toma el type_name ya parseado (e.g. "VARCHAR(255)", "CHAR(10)") y devuelve Some(n) si:

Casos en que devuelve None:

5. Enforcement en el encoder

Una sola línea agregada al arm de stores_as_text en encode_row:

if let Some(max) = column.max_length {
    if bytes.len() > max as usize {
        return Err(coded(
            codes::VALUE_LENGTH_EXCEEDED,
            format!("valor para columna '{}' excede {} bytes declarados ({} bytes recibidos)", ...),
        ));
    }
}

Como INSERT y UPDATE (incluyendo INSERT...SELECT y CTAS) terminan llamando al mismo encoder, el check cubre todos los paths sin duplicación.

6. Semántica: bytes UTF-8, no code points

max_length se mide en bytes UTF-8, igual que el length-prefixed encoding global (u16 para el largo). Esto difiere de PostgreSQL (character varying cuenta caracteres) pero coincide con MySQL VARCHAR ... CHARACTER SET utf8mb4 en bytes. La decisión se documenta para que un futuro modo de conteo por code points pueda agregarse como Option<LengthUnit> sin romper el wire format.

📐 Código de error nuevo

Código Nombre Cuándo
GBY-4119 VALUE_LENGTH_EXCEEDED INSERT/UPDATE de string que excede VARCHAR(n)/CHAR(n).

🧪 Validación

Suite y2_* en tests/integration_test.rs (8 tests):

Suite total: 510/510 pass (cargo test --lib --tests).

🔭 Futuro

📝 Actualización 2026-06-15: la mayoría de estos pendientes ya fueron entregados en bloques posteriores. Lista anotada abajo.

Pendientes que aún no entran: