v0.1.0

Arquitectura e internos

Como esta construido Velo por dentro: capas, protocolo, almacenamiento y TTL.


Capas del sistema โ€” Clean Architecture

Velo sigue los principios de Clean Architecture: las capas internas no conocen nada sobre las capas externas. El dominio no sabe nada de TCP. La aplicacion no sabe nada de RESP. La infraestructura no contiene logica de negocio.

Diagrama de arquitectura en capas de Velo
Diagrama 1 โ€” Arquitectura general y dependencias entre capas
Dominio
La capa mas interna. Contiene la entidad KeyEntry (un dato almacenado con su valor, tipo y TTL) y los contratos JSDoc que definen como debe comportarse el almacen (IDataStore) y los comandos (ICommandHandler). No tiene ninguna dependencia externa.
Aplicacion
Contiene los 23 casos de uso (comandos). Cada comando es una clase con el metodo execute(store, args, db) que implementa la logica pura del comando. El CommandRegistry los registra y despacha por nombre.
Infraestructura
La capa externa. Contiene el servidor TCP (TcpServer), el ciclo de vida por conexion (ClientConnection), el parser RESP (RespParser), el serializador (RespSerializer) y el almacen en memoria (InMemoryStore).
Configuracion
El modulo Config.js lee el archivo .env usando fs.readFileSync nativo, parsea los pares clave=valor, elimina comillas y exporta un objeto congelado (Object.freeze). Las variables de entorno del sistema tienen prioridad.

Flujo de un comando de extremo a extremo

Esta es la ruta completa que recorre un comando desde que el usuario lo escribe hasta que ve la respuesta en pantalla.

Diagrama de secuencia del flujo de un comando
Diagrama 2 โ€” Flujo de datos para el comando SET nombre joel EX 60
  1. Usuario escribe SET nombre joel EX 60 en el REPL.
  2. Cliente serializa el comando a formato RESP y lo envia por TCP.
  3. TcpServer recibe la conexion y crea un ClientConnection por socket.
  4. RespParser acumula los chunks de bytes y emite el evento 'comando' con el array de tokens.
  5. CommandRegistry busca el handler SetCommand por nombre.
  6. SetCommand valida argumentos, consulta el store y almacena la nueva entrada.
  7. RespSerializer convierte la respuesta JS { ok: 'OK' } a la cadena RESP +OK\r\n.
  8. Socket envia la cadena de vuelta al cliente, que la imprime en verde.

Estructura de archivos

tree
.
โ”œโ”€โ”€ .env                         โ† Configuracion: HOST, PORT, DB_COUNT...
โ”œโ”€โ”€ Makefile                     โ† Atajos: make server / make client / make dev
โ”œโ”€โ”€ package.json                 โ† @low-level-js/velo, sin dependencias
โ””โ”€โ”€ src/
    โ”œโ”€โ”€ server.js                โ† Entry point: crea store, registry y servidor
    โ”œโ”€โ”€ client.js                โ† Entry point: REPL interactivo con readline
    โ”œโ”€โ”€ config/
    โ”‚   โ””โ”€โ”€ Config.js            โ† Lee .env, exporta objeto congelado
    โ”œโ”€โ”€ domain/
    โ”‚   โ”œโ”€โ”€ entities/
    โ”‚   โ”‚   โ””โ”€โ”€ KeyEntry.js      โ† Entidad: value, type, expiresAt, isExpired()
    โ”‚   โ””โ”€โ”€ ports/
    โ”‚       โ”œโ”€โ”€ IDataStore.js    โ† Contrato del almacen (JSDoc)
    โ”‚       โ””โ”€โ”€ ICommandHandler.js โ† Contrato de comandos (JSDoc)
    โ”œโ”€โ”€ application/
    โ”‚   โ”œโ”€โ”€ CommandRegistry.js   โ† Registro y despacho de los 23 comandos
    โ”‚   โ””โ”€โ”€ usecases/
    โ”‚       โ””โ”€โ”€ phase1/
    โ”‚           โ”œโ”€โ”€ strings/     โ† 15 comandos: SET GET DEL EXISTS EXPIRE...
    โ”‚           โ””โ”€โ”€ server/      โ† 8 comandos: PING ECHO DBSIZE KEYS...
    โ””โ”€โ”€ infrastructure/
        โ”œโ”€โ”€ storage/
        โ”‚   โ””โ”€โ”€ InMemoryStore.js โ† Map<db, Map<key, KeyEntry>> + sweep
        โ”œโ”€โ”€ protocol/
        โ”‚   โ”œโ”€โ”€ RespParser.js    โ† Parser incremental de Buffers TCP
        โ”‚   โ””โ”€โ”€ RespSerializer.js โ† Convierte JS โ†’ cadenas RESP
        โ””โ”€โ”€ server/
            โ”œโ”€โ”€ TcpServer.js     โ† net.createServer + limite de conexiones
            โ””โ”€โ”€ ClientConnection.js โ† Ciclo de vida por socket

InMemoryStore โ€” el almacen

Todos los datos viven en memoria dentro de un objeto InMemoryStore. La estructura interna es un Map anidado: la clave externa es el indice de la base de datos (numero), y la clave interna es el nombre de la clave string.

Diagrama de la estructura interna del almacen en memoria
Diagrama 3 โ€” Estructura interna de InMemoryStore con multiples bases de datos
javascript โ€” estructura de datos
// _bases es el mapa raiz del almacen
_bases: Map<number, Map<string, KeyEntry>>

// Ejemplo del estado interno:
{
  0: {
    "nombre"  โ†’ KeyEntry { value: "joel",   expiresAt: null   },
    "sesion"  โ†’ KeyEntry { value: "tok123", expiresAt: 1741480800000 },
    "visitas" โ†’ KeyEntry { value: "42",    expiresAt: null   },
  },
  1: {}, // base 1 vacia
  // ...
  15: {}
}

API principal del store

MetodoDescripcion
get(db, clave)Devuelve la KeyEntry o null si no existe / expiro (lazy check)
set(db, clave, entry)Guarda una KeyEntry en la base db
del(db, ...claves)Elimina una o varias claves, devuelve el numero eliminado
exists(db, clave)Devuelve true si la clave existe y no expiro
keys(db, patron)Devuelve claves activas que coinciden con el patron glob
dbsize(db)Devuelve el numero de claves activas en la base
flushDb(db)Elimina todas las claves de una base especifica
flushAll()Elimina todas las claves de todas las bases
iniciarSweep()Inicia el timer de limpieza periodica
detenerSweep()Detiene el timer (llamado en shutdown)

Estrategia de expiracion โ€” TTL dual

Velo usa una estrategia de doble capa para manejar la expiracion de claves, igual que lo hacen los almacenes de datos en memoria de produccion:

1. Lazy expiry (expiracion perezosa)
Cada vez que se accede a una clave (en cualquier operacion get()), el store verifica si entry.isExpired() devuelve true. Si expiro, se elimina inmediatamente y se devuelve null.

Costo: O(1) por acceso. No agrega overhead si la clave no expiro.
2. Sweep periodico
Un setInterval recorre periodicamente todas las bases de datos y elimina las claves expiradas, aunque nadie las haya solicitado. El intervalo es configurable con SWEEP_INTERVAL_MS.

Se usa .unref() para que el timer no impida que Node.js cierre el proceso si no hay mas trabajo pendiente.
โœฆ
Por que dos estrategias? El lazy expiry es eficiente para claves que se leen frecuentemente (se detecta en la primera lectura). El sweep cubre el caso donde una clave nunca es leida despues de expirar โ€” sin el sweep, esa clave consumiria memoria hasta que el proceso terminara.
javascript โ€” fragmento de InMemoryStore.js
get(db, clave) {
  const mapa = this._bases.get(db);
  const entrada = mapa?.get(clave);
  if (!entrada) return null;

  // 1. Lazy expiry โ€” verificacion en cada acceso
  if (entrada.isExpired()) {
    mapa.delete(clave);
    return null;
  }
  return entrada;
}

iniciarSweep() {
  // 2. Sweep periodico โ€” limpia en segundo plano
  const timer = setInterval(() => {
    const ahora = Date.now();
    for (const mapa of this._bases.values()) {
      for (const [clave, entry] of mapa) {
        if (entry.expiresAt !== null && entry.expiresAt <= ahora) {
          mapa.delete(clave);
        }
      }
    }
  }, this._sweepMs);

  timer.unref(); // no bloquea el cierre del proceso
}

Entidad KeyEntry

Cada dato almacenado es una instancia de KeyEntry, la unica entidad del dominio:

javascript โ€” KeyEntry.js
export const TipoDato = Object.freeze({
  STRING: 'string',
  LIST:   'list',
  HASH:   'hash',
  SET:    'set',
});

export class KeyEntry {
  constructor(value, type = TipoDato.STRING, expiresAt = null) {
    this.value     = value;       // string | Array | Map | Set
    this.type      = type;        // TipoDato enum
    this.expiresAt = expiresAt;   // timestamp ms | null
  }

  isExpired() {
    return this.expiresAt !== null && Date.now() > this.expiresAt;
  }

  getTtlSegundos() {
    if (this.expiresAt === null) return -1;
    return Math.ceil((this.expiresAt - Date.now()) / 1000);
  }
}
PropiedadTipoDescripcion
valuestring | Array | Map | SetEl dato almacenado. En Fase 1 siempre es string.
typeTipoDato enumEl tipo de la clave. Requerido para respetar WRONGTYPE.
expiresAtnumber | nullTimestamp de expiracion en milisegundos. null significa sin expiracion.