@dotrino/store

中文文档
0.14.3发布于 2026/11/05MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 2612

Almacén compartido de hilos de mensajes para el ecosistema Dotrino (vault iframe + postMessage)

原始使用文档
文档目录12 节
  1. Almacenamiento: IndexedDB (desde v0.3.0)
  2. Por qué un subdominio aparte
  3. API
  4. Abrir el almacén: el saludo (desde 0.9.0)
  5. Hilos solo de este aparato: local.* (desde 0.14.1)
  6. Un almacén por perfil: connect({ identity }) (desde 0.10.0)
  7. Respaldo en la bóveda (desde 0.11.0)
  8. Garantías
  9. Deploy
  10. Schema
  11. Tests
  12. Auto-sync con Google Drive (0.2.0+)

@dotrino/store

Parte del ecosistema Dotrino. Dotrino es un ecosistema de aplicaciones centradas en la privacidad de los datos: tu información es tuya, y las decisiones sobre ella también — qué compartes, con quién, cuándo y por qué. Sin anuncios, sin cookies, sin rastreo de datos, sin vender tu identidad a nadie.

Almacén compartido de hilos de mensajes para el ecosistema Dotrino.

Mismo patrón que dotrino-identity: un iframe oculto servido desde store.dotrino.com mantiene los datos en su propio almacenamiento. Cualquier app del ecosistema (web messenger, extensión Chrome, futura app móvil PWA) que cargue este iframe en el mismo navegador comparte los mismos hilos.

Almacenamiento: IndexedDB (desde v0.3.0)

El backend del vault es IndexedDB (antes localStorage):

  • Cuota grande y dinámica según el disco (cientos de MB a GB), en vez del techo de ~5 MB de localStorage. Desaparece el riesgo de evicción del más viejo al llenarse el bucket compartido del origen.
  • Pide navigator.storage.persist() → almacenamiento persistente / no-evictable (best-effort).
  • Migración automática una sola vez: si hay datos en el localStorage anterior (cc.store.threads.v1), se copian a IndexedDB en el primer arranque.
  • Si IndexedDB no está disponible (p. ej. modo privado), cae a localStorage para no perder funcionalidad.
  • 100% local: no requiere cuenta ni terceros. (El sync opcional a tu Google Drive sigue aparte, cifrado y off por defecto.)
  • getStats() reporta backend, usage, quota y persisted.

Por qué un subdominio aparte

  • Los mensajes son volumen mucho mayor que las claves/contactos. Mantenerlos fuera del vault de identidad evita saturar ese almacenamiento.
  • Cada origen tiene su propia cuota. Subdominios distintos = aislar y sumar cuotas.
  • Permite evolucionar el schema de mensajería sin tocar el de identidad (más estable).

API

import { Store } from '@dotrino/store'

const store = await Store.connect()  // singleton — carga el iframe oculto

// El threadKey lo decide la app (típicamente la pubkey del contacto)
await store.appendMessage(contactPubkey, {
  dir: 'out',
  text: 'hola',
  ts: Date.now()
  // id se autogenera si no lo pasas
})

const entries = await store.listThread(contactPubkey, { limit: 50 })

const summaries = await store.getThreadSummaries()
// → { [pubkey]: { lastEntry, count } }   para sidebar de conversaciones

await store.removeThread(contactPubkey)
await store.clearAll()                    // borrar todo el almacén

const stats = await store.getStats()
// → { totalBytes, threadCount, threads: { [k]: { count, bytes } },
//     backend: 'indexeddb', usage, quota, persisted }

Abrir el almacén: el saludo (desde 0.9.0)

Store.connect() carga el iframe y espera su ready. Ese mensaje se manda una sola vez, al cargar la página: si se pierde, o si la página tarda más que el tope, quien esperaba se queda sin almacén y la app enseña «No se pudo abrir tu almacén». Qué cambió:

  • El cliente pregunta, además de escuchar. Mientras espera manda { _ccs: true, type: 'hello' } al iframe cada helloEveryMs (500 ms por defecto) y la página contesta ready. Un ready perdido deja de ser definitivo.
  • Abrir tiene su propio tope: connectTimeoutMs (20 s), aparte del de cada petición (timeoutMs, 8 s). Abrir es cargar una página por la red; una petición no.
  • Un fallo ya no dura toda la sesión. Hasta 0.8.0 la promesa rechazada se quedaba cacheada en el singleton, así que cualquier reintento —el botón «Reintentar» que enseñan las apps— devolvía el mismo error sin intentar nada. Ahora el fallo desmonta el iframe y el siguiente connect() levanta uno nuevo.
  • El error dice qué pasó: Store did not respond within 20000ms (https://store.dotrino.com/ never loaded).

Hilos solo de este aparato: local.* (desde 0.14.1)

Un hilo cuyo nombre empieza por local. se guarda igual que cualquier otro (IndexedDB, atado al perfil), pero no va a la bóveda: ni se sube, ni se baja, ni deja nada pendiente en el punto del respaldo. Es para lo que cambia mucho y no merece respaldo: dónde quedó tu personaje en un juego, un cursor, un borrador de segundos. Cada escritura de un hilo normal sale a la bóveda 1,5 s después; con la posición de un juego eso es una sincronización tras cada paso.

import { Store, LOCAL_PREFIX } from '@dotrino/store'   // LOCAL_PREFIX === 'local.'
await store.appendMessage('local.gridgame.position', { id: 'pos', ts: Date.now(), pos })

Lo que pierdes a cambio, y hay que saberlo al elegirlo: si se borra el navegador, ese hilo se va con él, y otro aparato tuyo no lo ve.

Un almacén por perfil: connect({ identity }) (desde 0.10.0)

Con identity (de @dotrino/identity), el almacén se ata al perfil activo: cada perfil del aparato tiene sus propios hilos, y si el perfil está emparejado con una bóveda, el almacén se respalda en ella.

const store = await Store.connect({ identity })
store.profileId   // 'p2419686e' — o null si se conectó sin identidad
  • Llegar tarde ya no deja sin perfil. El almacén es un singleton por página, y la moneda de <dotrino-support> lo abre sin identidad al montarse para contar la apertura; suele llegar antes que la app. Hasta 0.9.0, la app que después pedía connect({ identity }) recibía ese almacén sin perfil y todo lo suyo iba, sin ningún error, al espacio común de todos los perfiles del aparato. Ahora el almacén abierto adopta la identidad antes de devolverse.
  • Otro perfil se rechaza. Si el almacén ya está atado a un perfil y llega una identidad de otro, connect lanza con code: 'store-identity-mismatch'.
  • Sin perfil no se abre. Si la identidad no tiene perfil activo, connect lanza con code: 'store-no-profile'. Antes se caía al espacio por defecto en silencio.

Respaldo en la bóveda (desde 0.11.0)

Si el perfil está emparejado con una bóveda (dotrino-vault ≥ 0.115.0), lo que guardas se respalda en ella. Hasta 0.10.0 el almacén mandaba todo en un mensaje, el proxio corta los mensajes en 1 MB, y pasado ese tamaño el respaldo dejaba de llegar con el error tragado en un catch vacío. Ahora:

  • Se lee y se escribe siempre en el navegador. Responde al instante y sin conexión; abrir el almacén no espera a la bóveda.
  • Lo escrito sube por detrás, en tandas de menos de 1 MB. Si no sube, queda en pending y se reintenta.
  • Ponerse al día compara una huella por hilo y solo trabaja los que difieren: baja el índice (id y ts) y pide únicamente las entradas que faltan en cada lado. Al volver a la pestaña (tras un minuto) y cada 5 minutos con la pestaña a la vista.
  • Los borrados dejan lápida, así no vuelven desde la bóveda ni desde otro aparato. La lápida guarda el ts de lo borrado: una edición posterior sí entra. Se olvida a los 180 días.
  • Nada viaja en claro: identity.vaultStore cifra con la clave de contenido del perfil y, si el aparato aún no la tiene, falla con no-content-key (@dotrino/identity ≥ 0.91.0).
  • Las reglas viven en un solo sitio: store/core.js (export @dotrino/store/core), que usan la página y la bóveda.
const store = await Store.connect({ identity, maxPerThread: 50000 })

store.vault
// → { state: 'off' | 'syncing' | 'synced' | 'error',
//     reason: 'no-identity' | 'not-paired' | 'revoked' | null,
//     error: { code, message } | null,   // no-content-key · vault-no-reply · vault-outdated …
//     lastSyncAt, pending, tooLarge }
store.vaultBacked          // true si está al día y sin nada pendiente

store.on('vault', (s) => { if (s.state === 'synced' && s.changed.length) recargar(s.changed) })
await store.vaultSync()    // «Sincronizar ahora»: lanza con `code` si no se pudo

maxPerThread va en connect y no después: se aplica antes de la primera sincronización, que si no podría recortar lo que baja.

Límites que se saben:

  • Una entrada de más de ~550 KB no cabe por el proxio ni sola: no se respalda y aparece en tooLarge.
  • Un aparato apagado más de 180 días puede resucitar lo que se borró mientras tanto.
  • importThreads(..., 'replace') no deja lápidas de lo que desaparece.
  • Una reescritura con el mismo ts y otro contenido no cambia la huella: reescribir tiene que subir ts (appendMessage sin ts lo pone solo).

Garantías

  • Per-thread cap: 1000 mensajes por defecto, configurable con setMaxPerThread(n) o connect({ maxPerThread }) (hasta 50 000). El más antiguo se descarta al añadir uno nuevo si pasa el cap.
  • Eviction global ante QuotaExceededError: solo como red de seguridad (con IndexedDB es prácticamente inalcanzable). Descarta el 20% más antiguo a través de todos los hilos y reintenta hasta 8 veces.
  • No sale del navegador: nunca se hace fetch, no hay servidor, no hay analytics (salvo el sync opcional a tu Drive, off por defecto).

Deploy

GitHub Actions despliega a store.dotrino.com cuando cambia algo en store/. El bundle del iframe es estático (HTML + JS, sin build).

Schema

IndexedDB cc-store → object store kv → key threads.v1:

value: JSON { [threadKey: string]: ThreadEntry[] }

Por perfil, las claves llevan su id: threads.<perfil>.v1, opens.<perfil>.v1 y tombs.<perfil>.v1 (lápidas: { [threadKey]: { [id]: [ts, at] } }).

Las entradas son objetos opacos para el store; solo se les pide id y ts para deduplicación y ordenamiento.

Tests

npm install
npm test        # Playwright: sirve el vault y ejercita los handlers vía postMessage
                # (IndexedDB real: append/list/dedup, persistencia, migración, stats…)

Auto-sync con Google Drive (0.2.0+)

Backup cifrado y sync multi-dispositivo de los hilos contra appDataFolder de Google Drive. Mismo modelo y API que @dotrino/identity — los mensajes se cifran con AES-256-GCM (clave derivada por PBKDF2 600 000 iter de la passphrase) antes de subirse, así que Google solo ve bytes opacos.

await store.syncConnect(clientId)              // OAuth popup (scope: drive.appdata)
await store.syncUnlock('mi-passphrase')        // ≥12 chars
store.onSync(ev => console.log(ev.status))    // syncing | synced | offline | conflict | error
await store.syncNow()                          // forzar pull+push

Merge de hilos: unión por id, dedup, last-writer por ts, ordena ascendente, aplica maxPerThread después del merge. Append-only así que el merge es trivial — si dos dispositivos añaden mensajes a la vez, el resultado contiene los dos sets sin pérdida.

Nuevos métodos también para export/import manual:

const { threads } = await store.exportThreads()
await store.importThreads(threads, 'merge')   // o 'replace'

版本与发布记录

@dotrino/store 共发布 18 个版本,首次发布于 2026/06/18;过去 12 个月发布了 18 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.14.3latest2026/11/058 天前
0.14.22026/11/058 天前
0.14.12026/11/058 天前
0.13.02026/10/2816 天前
0.12.42026/10/1727 天前
0.12.32026/10/091 个月前

关于 @dotrino/store 的常见问题

@dotrino/store 是什么?

Almacén compartido de hilos de mensajes para el ecosistema Dotrino (vault iframe + postMessage)

如何安装 @dotrino/store?

在项目目录运行 npm install @dotrino/store,或使用 pnpm add / yarn add / bun add。

@dotrino/store 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@dotrino/store 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@dotrino/store 可以商用吗?

@dotrino/store 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@dotrino/store 的下载量有多少?

根据 npm 官方下载统计,@dotrino/store 在 2026/10/13 – 2026/11/11 这 30 天内被下载 2,612 次,日均约 87 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@dotrino/store 还在维护吗?

最新版本 0.14.3 发布于 2026/11/05(8 天前);至今共发布 18 个版本,其中过去 12 个月发布了 18 个;首次发布于 2026/06/18。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。