wandres.dev
INDEXEDDB I · el modelo

Claves e índices: el mapa de acceso

Claves en línea y fuera de línea, generadores automáticos que nunca reutilizan números, índices únicos, compuestos y multivalor, y los rangos que convierten un índice en una consulta de verdad.

⏱ 17 min

Sin lenguaje de consulta, todo lo que IndexedDB puede responder rápido está decidido de antemano por dos cosas: cómo se identifican los registros y qué proyecciones ordenadas mantienes sobre ellos. Las claves y los índices no son configuración accesoria, son el plan de acceso completo de tu base local, y se declaran en la migración, meses antes de que sepas qué preguntas te va a hacer la interfaz. Diseñarlos bien es la diferencia entre una consulta que responde en un milisegundo y un recorrido completo del almacén filtrando en JavaScript.

🎯 Al terminar esta lección sabrás
  • Elegir entre clave en línea y fuera de línea con criterio, no por costumbre.
  • Entender el generador automático: su monotonía, sus huecos y cuándo retrocede.
  • Crear índices únicos, compuestos y multivalor, y conocer sus incompatibilidades.
  • Construir rangos de consulta, incluidos los de prefijo sobre claves compuestas.

Dónde vive la clave

Un almacén decide, al crearse, si la clave forma parte del valor o vive fuera de él. Con keyPath la clave es en línea: el motor la extrae del propio objeto siguiendo una ruta de propiedades, que puede ser anidada como autor.id e incluso la cadena vacía, que significa que el valor entero es la clave. Sin keyPath la clave es fuera de línea y la pasas como segundo argumento en cada escritura.

// Clave en linea: el motor lee la propiedad isbn del objeto
const libros = db.createObjectStore("libros", { keyPath: "isbn" });
libros.put({ isbn: "978-84", titulo: "Rayuela" });

// Clave fuera de linea: el valor no sabe cual es su clave
const miniaturas = db.createObjectStore("miniaturas");
miniaturas.put(unBlob, "portada-978-84");

La regla práctica es simple: si el identificador pertenece conceptualmente al dato, ponlo en línea; si es un detalle de almacenamiento —una ruta, un hash de contenido, una clave binaria— déjalo fuera y ahorra bytes en cada registro. Los valores que no son objetos, como un Blob o una cadena, solo pueden usar clave fuera de línea.

🗄️

Clave en línea

Vive dentro del valor, extraída por keyPath. Admite rutas anidadas y es la opción natural para entidades con identidad propia.

🏷️

Clave fuera de línea

Se pasa aparte en cada escritura. Obligatoria cuando el valor no es un objeto, y más limpia para almacenes de contenido binario.

add frente a put

add falla con ConstraintError si la clave ya existe; put sobrescribe sin preguntar. La elección expresa tu intención, no tu comodidad.

🔢

Generador automático

Con autoIncrement el motor asigna la clave. Si además hay keyPath, escribe el número generado dentro del objeto que guardas.

El generador y sus huecos

El generador automático es un contador por almacén que empieza en uno y sube de uno en uno. Su propiedad esencial es que nunca reutiliza un número: borrar registros no lo hace retroceder, y vaciar el almacén entero tampoco. Los huecos son permanentes por diseño, porque un identificador reutilizado sería indistinguible de una referencia obsoleta.

Hay dos matices que casi nadie conoce. El primero es que escribir una clave explícita mayor que el contador lo empuja hacia arriba, de modo que la siguiente clave generada quedará por encima de la que insertaste a mano. El segundo es que abortar una transacción revierte el contador a su valor anterior, lo cual es coherente con la atomicidad, pero significa que no puedes usar la clave generada como un contador global de eventos.

const eventos = db.createObjectStore("eventos", {
  keyPath: "id",
  autoIncrement: true,
});

eventos.put({ texto: "uno" });   // id 1, escrito dentro del objeto
eventos.put({ texto: "dos" });   // id 2
eventos.delete(2);                // el contador no retrocede
eventos.put({ texto: "tres" });  // id 3, el hueco 2 no se reutiliza
eventos.put({ id: 900, texto: "manual" });
eventos.put({ texto: "cuatro" }); // id 901, el contador salto
ℹ️
Un generador no es un identificador de sincronización

En local-first, dos dispositivos con generadores independientes producirán la clave 42 para registros distintos. El autoincremento sirve como identificador local; la identidad que viaja por la red tiene que ser un identificador global, un UUID o un hash de contenido, guardado como propiedad del valor y respaldado por su propio índice único.

Índices: únicos, compuestos y multivalor

Un índice es una proyección ordenada de una propiedad hacia las claves primarias. Se crea dentro de la migración y admite dos banderas que cambian por completo su semántica.

const notas = db.createObjectStore("notas", { keyPath: "id" });

// Indice simple
notas.createIndex("por_fecha", "creado");

// Indice unico: rechaza duplicados en escritura
notas.createIndex("por_uuid", "uuid", { unique: true });

// Indice compuesto: orden lexicografico por componentes
notas.createIndex("por_libreta_fecha", ["libreta", "creado"]);

// Indice multivalor: una entrada por cada elemento del array
notas.createIndex("por_etiqueta", "etiquetas", { multiEntry: true });

unique impone una restricción real: cualquier escritura que duplique el valor aborta la transacción con ConstraintError. Y si creas el índice sobre datos que ya contienen duplicados, la migración entera falla, lo que es una forma incómoda pero honesta de enterarte de que tu invariante nunca fue cierta.

multiEntry solo tiene sentido cuando la propiedad es un array: genera una entrada de índice por elemento, y es la única manera de responder rápido a la pregunta “dame todas las notas con la etiqueta local-first”. No se puede combinar con un keyPath compuesto: son mutuamente excluyentes.

flowchart LR
A[Almacen de notas] --> B[Indice por libreta y fecha]
A --> C[Indice multivalor por etiqueta]
B --> D[Clave compuesta ordenada por componentes]
D --> E[Solo se puede acotar por prefijo]
C --> F[Una entrada por elemento del array]
F --> G[Consulta directa por etiqueta suelta]

El índice compuesto ordena lexicográficamente componente a componente, exactamente igual que un índice de árbol equilibrado en cualquier motor relacional. De ahí sale la restricción más importante y la que más gente ignora: solo puedes acotar por un prefijo de los componentes. Un índice sobre libreta y fecha responde consultas por libreta, y por libreta más rango de fecha; no responde nada que empiece filtrando por fecha.

Rangos de consulta

IDBKeyRange es la única forma de expresar una consulta que no sea una igualdad. Tiene cuatro constructores y dos banderas de exclusividad, y con eso se cubre todo el espacio de preguntas que IndexedDB sabe responder sin recorrerlo todo.

IDBKeyRange.only(7);                       // igualdad exacta
IDBKeyRange.lowerBound(fechaInicio);       // desde ahi hacia arriba
IDBKeyRange.upperBound(fechaFin, true);    // hasta ahi, sin incluirlo
IDBKeyRange.bound(desde, hasta, false, true); // intervalo semiabierto

// Prefijo de cadena: un punto de codigo altisimo como techo
const porNombre = IDBKeyRange.bound("mar", "mar\uffff");

// Prefijo de clave compuesta: un array vacio ordena despues de toda cadena
const deLaLibreta = IDBKeyRange.bound(["trabajo"], ["trabajo", []]);

Los dos últimos ejemplos son idiomas que conviene memorizar. El primero explota el orden lexicográfico de cadenas para simular un “empieza por”. El segundo explota el orden total entre tipos que viste en la primera lección: como cualquier array ordena después de cualquier cadena, el par formado por la libreta y un array vacío es un techo perfecto para todas las entradas de esa libreta, sea cual sea su fecha.

Un rango se puede pasar a casi cualquier método de lectura, y ahí está la diferencia entre una consulta eficiente y un recorrido disfrazado. getAll y getAllKeys aceptan además un límite de resultados, count responde sin materializar nada y get sobre un índice devuelve solo la primera coincidencia en orden de clave, lo que sorprende a quien espera una lista.

const idx = tx.objectStore("notas").index("por_fecha");

idx.get(rango);              // solo el primero, no una lista
idx.getAll(rango, 20);       // los veinte primeros del rango
idx.getAllKeys(rango, 20);   // sus claves primarias, sin deserializar valores
idx.count(rango);            // cuenta en el indice, sin leer registros
⚠️
Un rango no es un filtro

IDBKeyRange expresa un intervalo contiguo sobre un único orden, y nada más. No hay disyunción, no hay negación y no hay condiciones sobre otras propiedades. Todo lo que no sea un intervalo se resuelve recorriendo con un cursor y descartando en JavaScript, así que conviene diseñar el índice para que el intervalo sea lo más selectivo posible.

Los índices son el plan de consulta que escribes con meses de antelación y sin poder cambiarlo en caliente

En una base relacional el índice es una optimización que llega tarde y se corrige rápido: escribes la consulta que necesitas, mides, descubres que hace un recorrido completo, añades el índice con una sentencia y el planificador lo aprovecha sin que toques una línea de código. Ese ciclo, que damos por descontado, no existe en IndexedDB, y no por una carencia de la implementación sino por dos propiedades estructurales que se refuerzan entre sí. La primera es que no hay planificador: no existe un componente que mire tu consulta, examine los índices disponibles y elija el camino, porque no existe la consulta como objeto declarativo, solo existe el acceso imperativo que tú programas contra un índice concreto que nombras a mano. La segunda es que el índice solo puede nacer dentro de una transacción de actualización de versión, lo que significa que añadir uno no es un ajuste operativo sino un despliegue: exige subir el número de versión, exige que el usuario recargue, exige coordinar las pestañas abiertas y exige recorrer todos los registros existentes para construir la proyección, sobre un dispositivo que no controlas y con una batería que no es tuya. La conclusión es que en IndexedDB el diseño de índices se parece mucho más al diseño de un esquema de almacenamiento embebido que a la afinación de una base de datos: tienes que enumerar por adelantado las preguntas que tu interfaz hará durante el próximo año, decidir cuáles merecen una proyección ordenada y aceptar que el resto se responderán recorriendo y filtrando en JavaScript. Y como la regla del prefijo limita los índices compuestos a acotar por sus primeros componentes, el orden en que colocas esos componentes es una decisión de arquitectura que quedará congelada en la versión del esquema, no una preferencia estética. Pensar primero las consultas y después el almacén es aquí obligatorio, porque el coste de equivocarse no se paga en milisegundos sino en migraciones.

⚔️ Diseña el plan de acceso
  1. Crea el mismo almacén con clave en línea y fuera de línea, y compara el tamaño de los registros y la comodidad del código.
  2. Inserta con clave explícita 500, deja que el generador continúe y anota la siguiente clave asignada; después aborta una transacción y comprueba que el contador retrocede.
  3. Crea un índice único sobre una propiedad con duplicados existentes y observa cómo falla la migración completa.
  4. Escribe un índice compuesto y comprueba en la práctica que puedes acotar por su primer componente pero no por el segundo aislado.