Convenciones: los enteros de cabecera son little-endian; las claves del B-tree usan una codificación memcomparable big-endian (de modo que el orden de bytes es igual al orden de claves). La extensión de fichero recomendada es .arkeion, pero no se impone — el motor abre cualquier ruta, sin configuración.

Layout global

Una base de datos es un único fichero de páginas fijas de 4096 bytes. Solo tres regiones son especiales; todo lo demás se añade al final y nunca se sobrescribe.

db.arkeion — un fichero · páginas de 4096 bytes página 0 — cabecera del fichero inmutable tras crearse página 1 — ranura meta A página 2 — ranura meta B las únicas escrituras in situ — punteros de arranque alternos (una caché del último commit) páginas 3… — zona de solo añadir páginas de datos · páginas de commit — solo se añaden, nunca se reescriben crece →
Las dos ranuras meta son los únicos bytes que se sobrescriben, y no contienen más que punteros. Los datos y el historial son de solo añadir.
page 0        File header      (immutable after creation)
page 1        Meta slot A      (alternating rewrite, LMDB-style)
page 2        Meta slot B
page 3…       Append-only zone: data pages and commit pages

Página en disco — 4096 bytes

PAGE_SIZE = 4096 coincide con una página de SO y un sector lógico habituales, lo que minimiza las escrituras rotas (torn writes) y alinea la unidad de cifrado. PageId = u64, y el offset en bytes de una página es simplemente id × 4096.

Toda página tiene el mismo layout de tres bandas, cifrada o no — la reserva criptográfica (28 B, 0.7%) se paga siempre, a cambio de un B-tree totalmente agnóstico al cifrado.

cada página — 4096 bytes nonce 12 B etiqueta 16 B cuerpo 4068 B — cifrado o en claro 0 12 28 4096 la etiqueta ata el cuerpo a su page_id — una página movida a otro offset se lee como corrupción
Cifrada o no, los 4096 bytes quedan cubiertos. La integridad se verifica en cada lectura.
0..12    nonce   encrypted: 96-bit GCM counter        · plaintext: zeros
12..28   tag     encrypted: GCM tag, AAD = page_id    · plaintext: SHA-256(LE(page_id) ‖ body)[0..16]
28..4096 body    encrypted: ciphertext of the body    · plaintext: the body as-is

La etiqueta liga el contenido a su page_id: una página reubicada en otro offset se detecta como corrupción, no se acepta como datos válidos en el lugar equivocado. Sin cifrado, el nonce debe ser cero (se comprueba). La integridad se verifica siempre en lectura, en ambos modos. Con el cifrado activado, todas las páginas van cifradas salvo la cabecera y los slots meta — y esos no contienen datos de usuario, solo magics, punteros y versiones (ni siquiera los nombres de tabla quedan en claro).

Identificación de páginas

Las páginas de datos llevan su tipo en el primer byte del cuerpo:

Tipo Valor Contenido
Hoja del B-tree 0x01 Celdas clave→valor
Interna del B-tree 0x02 Celdas clave→hijo
Overflow 0x03 Continuación de valores grandes

Las páginas estructurales se identifican por un magic de 8 bytes en body[0..8] — no hay colisión posible, ya que ningún byte de tipo de datos vale 0x41 ('A'):

Página Magic Posición
Cabecera "ARKEION1" página 0
Slot meta "ARKMETA1" páginas 1–2
Commit "ARKCMT01" zona de append

Cabecera del fichero — cuerpo de la página 0

0..8     magic            "ARKEION1"
8..12    format_version   u32 = 1
12..16   page_size        u32 = 4096
16..20   flags            u32   bit0 = encryption enabled
20..36   file_id          [16]  random at creation (seed of the genesis chain)
36..52   kdf_salt         [16]  reserved (raw 32-byte key today, no KDF)
52..     reserved         zeros

Slot meta — cuerpo de las páginas 1 y 2

0..8     magic              "ARKMETA1"
8..16    version            u64   version of the last commit reflected
16..24   last_commit_page   u64
24..32   n_pages            u64   file length (in pages) at that commit

El escritor alterna A/B. Al abrir, el motor toma el slot válido (integridad de la etiqueta) con la version más alta y después hace un escaneo hacia delante desde n_pages, por si aterrizaron commits después de la última actualización del slot — la actualización del slot es perezosa, mantenida deliberadamente fuera del camino crítico de durabilidad.

Página de commit — cuerpo, tipo 0x04

0..8      magic           "ARKCMT01"
8..12     flags           u32   bit0 = compaction checkpoint (vacuum)
12..16    reserved        u32
16..24    version         u64   global monotonic, starts at 1
24..32    parent_page     u64   parent commit ON THE BRANCH (0 = genesis)
32..40    prev_page       u64   previous commit GLOBALLY (0 = genesis)
40..48    timestamp_ms    u64   wall clock, informational (version is authoritative)
48..56    data_root       u64   root of the branch's data tree
56..64    meta_root       u64   root of the global meta tree
64..72    nonce_counter   u64   next GCM counter after this commit
72..80    pages_written   u64   pages added by this commit
80..144   branch          [64]  UTF-8 branch name, zero-padded (max 64 B)
144..176  content_hash    [32]  SHA-256 of the PLAINTEXT bodies written by the commit, in order
176..208  prev_chain      [32]  chain_hash of the previous global commit
208..240  chain_hash      [32]  see below
240..     reserved        zeros

Cada commit cierra una cadena de hashes sobre el orden global de todos los commits, a través de todas las ramas:

cadena de hashes — lineal sobre el orden global de todas las ramas commit v41 content_hash de los cuerpos en claro chain_hash commit v42 content_hash chain_hash commit v43 content_hash chain_hash · cabeza prev_chain prev_chain reescribe cualquier commit pasado y cambian todos los chain_hash posteriores — verify() lo comprueba de extremo a extremo
La cadena es lineal sobre el orden global de los commits. Como content_hash cubre el texto en claro, un auditor verifica con la clave en la mano — y la cadena sobrevive a la rotación de claves mediante vacuum.
chain_hash = SHA-256( prev_chain ‖ content_hash ‖ LE(version) ‖ LE(timestamp_ms)
                      ‖ LE(data_root) ‖ LE(meta_root) ‖ branch )
genesis:     prev_chain = SHA-256( "ARKEION1" ‖ file_id )

Manipular cualquier escritura pasada, en cualquier rama, rompe la cadena de ese punto en adelante. Un commit con flags.checkpoint = 1 lo escribe vacuum: su prev_chain lleva el chain_hash de cabeza de la historia truncada, de modo que la cadena sigue siendo continua y verificable aunque las páginas antiguas ya no estén.

Páginas del B-tree

leaf (0x01):     [type u8][flags u8][ncells u16] · [plen varint][prefix]? · cells · pointers
                 flags bit0 = COMPRESSED PREFIX: a [plen varint][prefix] shared by every key
                   follows the header, and each cell stores only the SUFFIX key[plen..];
                   full key = prefix ++ suffix. Without the bit: empty prefix, cell = whole key.
                 cell: [cell-flags u8][klen|slen varint][key|suffix][vlen varint][val]
                 cell-flags bit0 = overflow: [...][total len varint][first overflow u64]
                 (a value pushing the cell past 1280 B goes to overflow; key max 1024 B)
internal (0x02): [type][flags][ncells u16][rightmost u64] · cells: [klen varint][key][child u64]
                 cell.key = exclusive upper bound of its child
overflow (0x03): [type][flags][len u16][next u64][bytes…]

Cada nodo termina con un array de punteros de celda — un u16 LE por celda, creciendo desde el final del nodo hacia el contenido — de modo que el descenso, get y los scans hacen búsqueda binaria dentro de la página sin materializar el nodo, y añadir la clave máxima sigue siendo O(1). Coste: 2 B por celda.

Compresión de prefijos en hojas. Las claves de una hoja suelen compartir un prefijo largo — postings de un término ([0x03, fts_id, 0x00, term_id]), entradas de un índice ([0x02, index_id]). Almacenar ese prefijo una vez por página en lugar de una vez por celda encoge todos los índices (es gran parte de por qué el full-text quedó por debajo del FTS5 de SQLite), sin cambiar el modelo lógico: las comparaciones del hot-path recortan el prefijo del objetivo y comparan sufijos zero-copy; solo el cursor de scan reconstruye la clave completa. El cursor de append añade un sufijo cuando la clave comparte el prefijo, así que la hoja activa más a la derecha se mantiene comprimida con appends O(1), y las páginas antiguas se leen de la misma manera.

Invariantes comprobadas al decodificar: las claves no están vacías y son estrictamente crecientes dentro de un nodo. delete no reequilibra nodos infra-llenos — solo elimina los vacíos; vacuum reequilibra al reescribir.

Espacios de claves

El catálogo almacena una versión de esquema con cada tabla. El esquema lógico ha crecido v7 (restricciones CHECK) → v8 (full-text) → v9 (vectorial) → v10 (re-rank vectorial) — cada paso añade espacios de claves, así que el format_version en disco (página 0) sigue en 1. Esta es la progresión canónica a la que se refieren los docs de SQL, full-text y vectores.

Árbol de datos (data_root — se bifurca con la rama)

[0x00, 0x01, table_name UTF-8]              → {table_id u32, schema}   catalogue
[0x00, 0x02, table_id BE]                   → next_rowid u64           rowid counter
[enc_oint(table_id), enc_oint(rowid)]       → record (row)
[0x02, index_id BE, value*, rowid u64 BE]   → secondary index entry

enc_oint es un i64 que preserva el orden, autodelimitado y de longitud variable: una cabecera de un byte codifica el signo y el número de bytes significativos, de modo que las magnitudes pequeñas ocupan ~2 B mientras el orden (table_id, rowid) del B-tree se preserva con exactitud. La clave de fila no necesita byte de espacio de nombres — table_id ≥ 1 produce una cabecera enc_oint de 0x80+, disjunta de 0x00 (catálogo) y 0x02 (índice), así que una clave se autoidentifica por su primer byte. Las entradas de índice mantienen un rowid u64 BE fijo como sufijo final tras el valor de longitud variable.

El esquema vive en el árbol de datos a propósito: una migración en una rama cambia el esquema solo en esa rama.

Árbol meta (meta_root — global, lineal, nunca se bifurca)

[0x01, branch_name]                → {head_version u64, head_page u64}    refs
[0x02, version BE]                 → {commit_page u64, ts_ms u64, branch}  history index
[0x03, ts_ms BE, version BE]       → ∅                                     AS OF TIMESTAMP

Registro (fila)

[ncols varint][tag u8 × ncols][payloads in order]

tags:  0 NULL · 1 FALSE · 2 TRUE · 3 INTEGER (zigzag varint)
       4 REAL (f64 LE, 8 B) · 5 TEXT (varint len + UTF-8) · 6 BLOB (varint len + bytes)

Las columnas finales ausentes se leen como NULL — que es lo que permite que ALTER TABLE ADD COLUMN funcione sin reescribir ni una sola fila existente.

Protocolo de commit y recuperación

Una escritura, desde el único escritor:

1. new pages (copy-on-write)  → append
2. commit page                → append
3. fdatasync                  ← the durability point — exactly one
4. alternating meta slot      → write in place (lazy hint; no fsync)

Un fdatasync por commit, sin barrera entre las páginas de datos y la página de commit. La etiqueta de integridad por página hace ilegible cualquier página escrita a medias o sin escribir; la recuperación escanea en orden y se detiene en la primera página ilegible. Como las páginas de datos se añaden antes que la página de commit, un commit a medio escribir nunca se adopta — solo un commit cuyas páginas son todas legibles (el fdatasync llegó a completarse) se convierte en cabeza. Prescindir de la segunda barrera vale aproximadamente 2–2.5× la tasa de escritura durable frente a un rollback journal de SQLite, a la par del WAL, conservando la durabilidad completa de fichero único.

Apertura y recuperación:

1. Validate the header (magic, format version, flags).
2. Read meta A and B → candidate = the valid one with the highest version. Both corrupt → full scan.
3. Validate the candidate commit page (tag + magic + optional chain).
4. Scan forward from n_pages: each further valid commit page advances the head.
5. A broken tail (pages no valid commit covers) is ignored.

El paso 5 es el “replay del WAL”: no hay redo ni undo, solo descartar lo que nunca se confirmó.

Vacuum (compactación)

vacuum(retention) reescribe las versiones vivas en un fichero temporal según la política — KeepAll | KeepLast(u64) | KeepSince(SystemTime) —, escribe un commit checkpoint que encadena con la historia truncada y después hace un fsync + rename atómico sobre el original. Hace también de mecanismo de rotación de claves: todo se reescribe bajo la clave nueva.