npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@qhel/sdk

v0.9.0

Published

SDK cliente de Qhel: sync colaborativo en vivo, offline-first y búsqueda/duplicados semánticos. React Native/Expo, navegador y Node.

Readme

@qhel/sdk

SDK cliente de Qhel — la base de datos realtime + semántica nativa: lo que escribes es instantáneamente visible y semánticamente buscable por todo el equipo, en el mismo segundo. Funciona en React Native / Expo, navegador y Node.

  • Sync colaborativo en vivo (WebSocket): cambios propagados al instante.
  • Offline-first (CRDT): escribe sin conexión y reconcilia al reconectar.
  • Búsqueda y duplicados semánticos: search, similar y aviso onDuplicate.
  • API de base de datos completa: query server-side (WHERE/orderBy/paginación keyset), concurrencia optimista (expectedVersion/CAS), contadores conmutativos (increment/decrement), transacciones multi-item, count/getMany y export (portabilidad).

Instalación

npm install @qhel/sdk
# o: pnpm add @qhel/sdk / yarn add @qhel/sdk

Node ≥ 22 trae WebSocket global. En entornos sin él, pasa socketFactory en las opciones.

Credenciales

La url del engine y las credenciales del proyecto se obtienen desde el panel / control plane de Qhel. Hay dos clases de token (ADR-030):

  • Credencial de servicio (larga, a nivel de proyecto): da acceso a todo el proyecto con su rol. Custódiala en tu backend — nunca la embebas en una app pública/multiusuario.
  • Token de usuario final (efímero, firmado): lo acuña tu backend por usuario (POST /api/tokens/mint con la credencial de servicio) y da acceso solo a las filas de ese usuario (RLS por-fila, aplicada por el engine). Es lo que va en el cliente.

App multiusuario: tokenProvider (recomendado)

Pasa un tokenProvider en vez de un token estático: el SDK lo llama al conectar y en cada reconexión, así el token de usuario está siempre fresco y nunca se persiste. Tu callback pide un token a tu backend, que lo acuña con su credencial de servicio; el SDK nunca ve la credencial de servicio.

const db = new Qhel({
  url: "wss://<tu-engine>",
  // Tu backend acuña un token efímero para el usuario logueado y lo devuelve.
  tokenProvider: async () => {
    const res = await fetch("/api/qhel-token", { method: "POST" });
    const { token } = await res.json();
    return token;
  },
});

Un cliente por usuario final. Cada Qhel queda fijado al primer usuario (sub) que autentica: si el tokenProvider devuelve luego el token de OTRO usuario, la conexión falla a propósito (crea un cliente nuevo al cambiar de usuario y cierra el anterior al desloguear). Y si una cola offline persistida pertenece a otro usuario (dispositivo compartido), el SDK no la sincroniza bajo la identidad equivocada — la preserva hasta que ese usuario vuelva — para no corromper la atribución ni perder datos por RLS. (Las escrituras hechas antes de la primera conexión, sin usuario conocido aún, se atribuyen a quien conecte primero; el servidor es la autoridad de created_by.)

Para un backend server-to-server (o desarrollo), sigue valiendo un token estático (credencial de servicio, sin RLS por-usuario).

Quickstart

import { Qhel, newId } from "@qhel/sdk";

const db = new Qhel({
  url: "wss://<tu-engine>",   // p. ej. wss://api.qhel.dev
  token: "<token-del-proyecto>",
});

// Conecta y reacciona a los eventos del servidor.
await db.connect({
  onChange: (item) => console.log("cambio en vivo:", item),
  onRemoved: (id) => console.log("eliminado:", id),
  // El "wow": tu creación se parece a algo EXISTENTE, detectado por significado.
  // `item`/`matchId` son el item existente parecido (matchId === item.id);
  // `sourceId` es el id de lo que acabas de crear (para correlacionar). Se llama
  // una vez por cada existente parecido (hasta 5).
  onDuplicate: (item, matchId, score, sourceId) =>
    console.log(`"${sourceId}" se parece a "${matchId}" (${Math.round(score * 100)}%)`, item),
  onError: (code, message) => console.error("error:", code, message),
  // Estado de la conexión (S1): connecting → connected → reconnecting → offline.
  // Para una app local-first: muéstrale al usuario si trabaja sin conexión.
  onConnectionState: (state) => console.log("conexión:", state),
  // Ciclo de vida de cada escritura (S2): queued (offline) → synced (ack), o sent
  // (en vivo); error si el lote offline falló (se reintenta).
  onWrite: (e) => console.log(`escritura ${e.id}: ${e.status}`),
  // Realtime sin pérdida (gap 12): si este cliente se quedó atrás (consumidor lento) y
  // el engine descartó su suscripción, avisa aquí para RE-SINCRONIZAR: re-haz `list(ws)`
  // y repón tu vista (también refleja bajas), y `subscribe(ws)` de nuevo. No es un error.
  onResync: async (ws) => { render(await db.list(ws)); db.subscribe(ws); },
});

// El estado de conexión también se puede leer puntualmente:
db.connectionState; // "connecting" | "connected" | "reconnecting" | "offline"

// Carga inicial: el estado actual del workspace (la verdad del servidor) para
// pintar al abrir; luego subscribe aplica el realtime encima. Patrón:
// connect → list → subscribe.
const items = await db.list("inbox");          // todo
// const page = await db.list("inbox", { limit: 100, cursor: lastId }); // paginado

// Suscríbete a los cambios en vivo de un workspace (desde este momento).
db.subscribe("inbox");

// Escribe: en vivo si hay conexión, encolado offline si no (se sincroniza solo).
// `newId()` genera un id único robusto en Expo/Hermes (donde `crypto.randomUUID`
// no existe); aquí usamos uno fijo para poder actualizar/borrar en el ejemplo.
const id = newId();
db.create("inbox", { id, workspace: "inbox", fields: { title: "comprar pan" } });
db.update("inbox", id, { title: "comprar pan y leche" });
db.remove("inbox", id);

Sobre onDuplicate

  • item es el existente parecido, no el que acabas de crear; matchId === item.id. Tú ya tienes el nuevo (lo creaste); sourceId te dice cuál fue.
  • Se dispara una vez por cada existente parecido que supere el umbral (hasta 5), no un único aviso agregado.
  • El umbral de similitud se calibra con tu feedback; con textos muy cortos el modelo multilingüe puede emparejar cosas flojamente relacionadas. Trátalo como señal "a revisar", no como verdad.
  • Devuélvenos el veredicto para afinar el umbral y medir la precisión: cuando el usuario acepta o descarta un aviso, llama a reportDuplicateDecision (requiere controlPlaneUrl en el constructor). Es opcional y no crítico (si falla, no rompe nada):
    const db = new Qhel({ url, token, controlPlaneUrl: "https://app.qhel.dev" });
    // en tu handler onDuplicate, cuando el usuario decide:
    await db.reportDuplicateDecision({ sourceId, matchId, score, accepted: true /* o false */ });

Búsqueda semántica

search y similar devuelven ScoredItem[] — cada resultado es { item, score } con la similitud coseno en [0,1], para que puedas rankear, mostrar confianza o filtrar con tu propio criterio.

// En lenguaje natural; incluye lo recién escrito (sin reindexado batch).
const results = await db.search("inbox", "cosas del supermercado", 10);
for (const { item, score } of results) {
  console.log(`${item.fields.title} — ${Math.round(score * 100)}%`);
}

// Ítems semánticamente cercanos a uno dado (también con score).
const similar = await db.similar("inbox", "task-1", 5);

// `limit` y `threshold` opcionales (consistencia con `check`): con `threshold`
// el engine filtra por score SERVER-SIDE y no te manda los matches flojos.
// Útil sobre todo en consultas muy cortas (1-2 palabras), donde el modelo
// comprime los scores: recorta el ruido en el origen en vez de en el cliente.
const fuertes = await db.search("inbox", "carne", { limit: 10, threshold: 0.5 });
const cercanos = await db.similar("inbox", "task-1", { threshold: 0.6 });

Comprobar duplicados ANTES de insertar (check)

onDuplicate llega después del create. Si quieres decidir si insertas (en vez de "insertar y quizá borrar"), usa check: un dry-run que devuelve los candidatos parecidos con su score sin escribir nada.

const dups = await db.check("inbox", "comprar leche", { limit: 5, threshold: 0.7 });
if (dups.length === 0) {
  db.create("inbox", { id, workspace: "inbox", fields: { title: "comprar leche" } });
} else {
  // dups[0].item es el existente más parecido; dups[0].score, su similitud.
}

limit y threshold son opcionales (defaults del engine: 5 candidatos, umbral de duplicado). El umbral sigue sin calibrar; trátalo como señal "a revisar".

Query server-side (filtrar, ordenar, paginar)

search busca por significado; query es el filtrado exacto/estructurado server-side (ADR-031): predicados where en AND, orderBy y paginación keyset (cursor opaco). Resuelve con { items, nextCursor }.

const page = await db.query("pedidos", {
  where: [{ field: "estado", op: "eq", value: "abierto" }],
  orderBy: { field: "createdAt", dir: "desc" },
  limit: 50,
});
render(page.items);
if (page.nextCursor) {
  const next = await db.query("pedidos", { /* mismos where/orderBy */ cursor: page.nextCursor });
}

Operadores: eq/ne/lt/lte/gt/gte/between/prefix/isNull/isNotNull. Comparación type-aware (un exact compara numéricamente, un datetime temporalmente); un desajuste de tipo o un campo ausente no matchea (nunca peta). createdAt/createdBy son consultables. Además: count(ws) da el total de items; getMany(ws, ids) lee un lote por id en una sola ida y vuelta.

Concurrencia optimista (compare-and-set)

Cada item expone un version opaco. Pásalo como expectedVersion a update/remove y el engine rechaza la escritura con onError('conflict', …) si el item cambió desde que lo leíste (ADR-032, online; el sync offline sigue siendo LWW). Sin expectedVersion, la escritura es incondicional (último-en-escribir-gana), como antes.

const [item] = await db.getMany("inbox", ["task-1"]);
db.update("inbox", "task-1", { estado: "hecho" }, item.version); // CAS
// si otro escribió antes → onError("conflict", …): re-lee su version y reintenta.

Contadores conmutativos

Para cantidades que se acumulan (votos, reacciones, stock), usa increment/decrement en vez de update: dos ajustes concurrentes suman, no se pisan (PN-Counter, ADR-029). Afloran como un campo numérico del item. Online-only en v1 (devuelve false si no hay conexión).

db.increment("posts", "p-1", "likes");        // +1
db.decrement("almacen", "sku-9", "stock", 3); // -3

Transacciones multi-item

transaction aplica un lote de mutaciones atómicamente (todas o ninguna) en un workspace — para invariantes entre registros (mover saldo de A a B, crear un pedido y su línea). Si una falla, onError nombra la op culpable y nada se aplica. Online-only (ADR-033).

import { tx } from "@qhel/sdk";
db.transaction("banco", [
  tx.update("cuenta-A", { saldo: 90 }),
  tx.update("cuenta-B", { saldo: 110 }),
]);
// builders: tx.create({ id, workspace, fields }) · tx.update(id, patch, expectedVersion?)
//           · tx.remove(id, expectedVersion?) · tx.increment(id, field, amount?) · tx.decrement(...)

Portabilidad (export / import)

Tu dato es tuyo: export pagina todo el workspace y devuelve sus items; Qhel.toImportable les quita los campos que sella el servidor (createdAt/createdBy/version) para reimportarlos con bulkLoad (clonar un workspace, migrar, sembrar datos).

const items = await db.export("inbox");
const dump = JSON.stringify(items); // guárdalo/descárgalo donde quieras
// ...más tarde, en otro workspace/proyecto:
await db.bulkLoad("inbox-copia", Qhel.toImportable(items));

Offline

No hay que hacer nada especial: si no hay conexión, create/update/remove se acumulan en una cola CRDT por workspace y se reconcilian automáticamente con el servidor al (re)conectar. El delta solo se descarta cuando el servidor confirma el sync (ack), así que una caída a mitad de la reconexión no pierde lo escrito. La búsqueda/duplicados requieren conexión (el índice vive en el servidor); tras reconectar, Qhel revisa en silencio los duplicados de lo offline.

Durabilidad (sobrevivir a cerrar la app)

Por defecto la cola vive en memoria. Para que lo escrito sin conexión sobreviva a cerrar/matar la app, pásale un storage (KV asíncrono: AsyncStorage en React Native/Expo, o un envoltorio de localStorage) y llama a loadOffline() al arrancar, antes de escribir:

import AsyncStorage from "@react-native-async-storage/async-storage";

const db = new Qhel({ url, token, storage: AsyncStorage });
await db.loadOffline(); // rehidrata la cola de la sesión anterior
// ...escribe y conecta con normalidad

Lectura offline (pintar al arrancar sin red)

Con storage, cada list() completo se cachea. cachedList(ws) devuelve ese último snapshot sin conexión, para pintar al instante al abrir la app; luego refrescas con list() cuando haya red:

const cached = await db.cachedList("inbox"); // instantáneo, sirve offline
render(cached);
await db.connect(/* ... */);
render(await db.list("inbox")); // verdad del servidor cuando hay red

Limpiar datos (borrado masivo)

db.removeMany("inbox", ["task-1", "task-2"]); // varios por id (en vivo u offline)
db.clearWorkspace("inbox");                    // vaciar el workspace (requiere conexión)

clearWorkspace borra todos los items del workspace (para "empezar de cero" / limpiar datos de prueba); el workspace sigue existiendo. No es el borrado de cuenta.

Diagnóstico (modo debug)

const db = new Qhel({ url, token, debug: true });
// Registra por console.debug cada mensaje de protocolo: "[qhel] → {...}" / "[qhel] ← {...}"

Códigos de error (onError(code, message))

El error es producto de primera clase: el SDK lo entrega por onError, nunca lo traga. Qué significa cada code y qué hacer:

| code | Qué pasó | Qué hacer | |---|---|---| | forbidden | El rol del token no permite la operación (p. ej. un viewer intenta escribir). | Usa una credencial con rol suficiente (owner/editor). No reintentes igual. | | rejected | El engine rechazó la mutación (id duplicado al crear, o id inexistente al actualizar/borrar). | Corrige el id/operación; es un error de uso, no transitorio. | | badRequest | Mensaje mal formado o campo con un valor de tipo no soportado. | Revisa el payload; no reintentes sin cambiarlo. | | quotaExceeded | El proyecto superó la cuota de su tier (conexiones, items o escrituras/mes). | Espera/cierra conexiones, sube de plan, o reduce el ritmo. El estado de cuota se ve en el panel. | | conflict | Conflicto de concurrencia optimista (CAS, ADR-032): tu expectedVersion estaba obsoleto — el item cambió desde que lo leíste. | Re-lee el item (su version) y reintenta el update/remove. | | incompatibleVersion | La versión de protocolo del SDK y el engine no son compatibles. | Actualiza @qhel/sdk. Es terminal: el SDK no reintenta. |

Módulos activos y cuota se consultan en el control plane (panel o su API), no en el engine (frontera ADR-025: el engine solo hace geometría). Para saber si esperar señales de Data Quality (onSignal) o ver límites/uso, consulta el panel del proyecto o GET /api/projects/:id/modules.

API (resumen)

| Método | Qué hace | |---|---| | new Qhel({ url, token, socketFactory?, identity?, queryTimeoutMs?, storage?, debug? }) | Crea el cliente | | loadOffline() | Rehidrata la cola offline persistida en storage (llamar al arrancar) | | connect(handlers?) | Abre la conexión; resuelve al recibir welcome | | create(ws, item) / update(ws, id, patch, expectedVersion?) / remove(ws, id, expectedVersion?) | Escrituras (en vivo u offline). expectedVersion = compare-and-set (CAS) online (ADR-032) | | removeField(ws, id, field) | Unset de un campo: lo deja ausente, no null (online; boolean) — A5/ADR-027 | | increment(ws, id, field, amount?) / decrement(ws, id, field, amount?) | Contador conmutativo (PN-Counter; online; boolean) — ADR-029 | | transaction(ws, ops) | Lote de mutaciones aplicado atómicamente (todo o nada), online — ADR-033 | | removeMany(ws, ids) / clearWorkspace(ws) | Borrado masivo / vaciar el workspace | | bulkLoad(ws, items) | Importa muchos items NUEVOS en un lote durable (requiere conexión; resuelve con el nº importado; idempotente por id) — ADR-028 | | subscribe(ws) | Activa el stream de cambios en vivo (llegan por onChange) | | list(ws, { limit?, cursor? }) / cachedList(ws) | Carga inicial (servidor) / lectura offline cacheada | | query(ws, { where?, orderBy?, limit?, cursor? }) | Query server-side: filtro WHERE + orderBy + paginación keyset (Promise<QueryPage>) — ADR-031 | | count(ws) | Nº de items vivos del workspace (Promise<number>) | | getMany(ws, ids) | Lee varios items por id en una sola ida y vuelta (Promise<Item[]>) | | search(ws, query, limit) / similar(ws, id, limit) | Búsqueda semántica (Promise<ScoredItem[]>, con score) | | check(ws, text, { limit?, threshold? }) | Dry-run de duplicados pre-insert (Promise<ScoredItem[]>) | | export(ws, { pageSize? }) / Qhel.toImportable(items) | Exporta todo el workspace (Promise<Item[]>) y prepara el dump para re-importar con bulkLoad (portabilidad) | | close() | Cierra la conexión (sin reintentar) |

Tipos de los campos

Los fields aceptan tipos (modelo de datos): texto, número, booleano, null, dos tipos con helper (datetime/exact), y valores anidados — arrays y objetos (recursivos): fields: { tags: ["a", "b"], direccion: { ciudad: "Madrid" } }. Los anidados se guardan/recuperan tal cual y se fusionan como valor completo (LWW); no son consultables por query ni alimentan la capa semántica (v1). Ejemplo de los escalares y los dos helpers:

import { Qhel, datetime, exact } from "@qhel/sdk";

db.create("pedidos", {
  id: "p-1",
  workspace: "pedidos",
  fields: {
    nota: "envío urgente",          // texto
    unidades: 3,                     // número
    pagado: false,                   // booleano
    entrega: datetime(new Date()),   // fecha/hora → el engine la guarda en UTC
    precio: exact("10.50"),          // cantidad exacta (dinero): NUNCA float
  },
});
  • datetime(Date | string) (A3): se guarda como instante UTC canónico. La zona/el formato local son cosa de tu capa de presentación. En la entrada se acepta cualquier desfase RFC 3339 y el engine lo normaliza a UTC.
  • exact(string | number) (A4): cantidad exacta para dinero y valores que no admiten redondeo; se guarda como decimal en texto, sin coma flotante. Pásalo como cadena (exact("10.50")) para no perder precisión.
  • Ausencia ≠ vacío ≠ cero (A5): un campo que no está es "falta"; "" y 0 son valores presentes. La señal de campo faltante respeta esa distinción.

Solo se interpreta por significado (búsqueda/duplicados) el texto de los campos declarados semánticos; números, fechas, exactos, emails o identificadores no participan de la capa semántica (se declaran en el panel del proyecto).


Qhel es software propietario (ver LICENSE). El uso del SDK está sujeto a los términos del servicio de Qhel.