Document database en vanilla JS — zero dependencias. Corre en Node.js, browser, Cloudflare Workers, Deno, Bun.
Queries estilo MongoDB con indices, joins, aggregation, encriptacion y autenticacion. Un solo archivo.
🧩 Ecosistema completo (js-doc-store -> js-store -> js-base + js-vector-store):
ECOSYSTEM.md— que es cada capa y cual usar (alojado en el repo de js-base).📄 Reporte de analisis de la jornada (cubre los tres repos de la cadena js-doc-store -> js-store -> js-base):
ANALISIS-2026-07-06.md(alojado en el repo de js-base).
npm install js-doc-storeO copiar el archivo directamente:
cp js-doc-store.js tu-proyecto/const {
DocStore,
MemoryStorageAdapter,
FileStorageAdapter,
CloudflareKVAdapter,
EncryptedAdapter,
FieldCrypto,
Auth,
Table,
TEMPLATES,
createFromTemplate,
} = require('js-doc-store');const db = new DocStore(new MemoryStorageAdapter());
// o persistente: new DocStore('./data')
// o Cloudflare KV: new DocStore(new CloudflareKVAdapter(env.MY_KV))
const users = db.collection('users');
users.createIndex('email', { unique: true });
users.insert({ name: 'Alice', email: 'alice@test.com', age: 30 });
users.insert({ name: 'Bob', email: 'bob@test.com', age: 25 });
users.find({ age: { $gte: 18 } }).sort({ age: -1 }).limit(10).toArray();
db.flush(); // persiste a disco/KV// Auto-genera _id
const doc = users.insert({ name: 'Alice', age: 30 });
// doc._id = 'lxyz123-abc-1'
// _id manual
users.insert({ _id: 'custom-id', name: 'Bob' });
// Batch
users.insertMany([{ name: 'C' }, { name: 'D' }]);users.findById('custom-id');
users.findOne({ email: 'alice@test.com' });
// Cursor (lazy — no ejecuta hasta toArray/first/count)
users.find({ age: { $gte: 18 } })
.sort({ age: -1 })
.skip(20)
.limit(10)
.project({ name: 1, age: 1 })
.toArray();
users.find({ city: 'Madrid' }).first();
users.find({ active: true }).count();users.update({ email: 'x@test.com' }, { $set: { age: 31 } });
users.updateMany({ active: false }, { $set: { archived: true } });users.remove({ email: 'x@test.com' });
users.removeMany({ archived: true });
users.removeById('custom-id');users.count(); // total
users.count({ age: { $gte: 18 } }); // con filtro| Operador | Ejemplo | Descripcion |
|---|---|---|
| igualdad | { name: 'Alice' } |
Campo es exactamente el valor |
$eq |
{ age: { $eq: 30 } } |
Igual (explicito) |
$ne |
{ status: { $ne: 'deleted' } } |
No igual |
$gt |
{ age: { $gt: 18 } } |
Mayor que |
$gte |
{ age: { $gte: 18 } } |
Mayor o igual |
$lt |
{ price: { $lt: 100 } } |
Menor que |
$lte |
{ price: { $lte: 100 } } |
Menor o igual |
| Operador | Ejemplo | Descripcion |
|---|---|---|
$in |
{ status: { $in: ['active', 'pending'] } } |
Valor en lista |
$nin |
{ role: { $nin: ['banned'] } } |
Valor NO en lista |
| Operador | Ejemplo | Descripcion |
|---|---|---|
$exists |
{ phone: { $exists: true } } |
Campo existe |
$regex |
{ name: { $regex: '^Al' } } |
Match regex |
$contains |
{ tags: { $contains: 'admin' } } |
Array contiene valor |
$size |
{ tags: { $size: 3 } } |
Array tiene N elementos |
| Operador | Ejemplo | Descripcion |
|---|---|---|
$and |
{ $and: [{ age: { $gte: 18 } }, { active: true }] } |
Todos deben cumplir |
$or |
{ $or: [{ city: 'Madrid' }, { city: 'Barcelona' }] } |
Al menos uno cumple |
$not |
{ $not: { status: 'deleted' } } |
Niega el filtro (top-level) |
$not |
{ stock: { $not: { $eq: 0 } } } |
Niega operador a nivel de campo |
users.find({ 'address.city': 'Madrid' });
users.find({ 'profile.settings.theme': 'dark' });| Operador | Ejemplo | Efecto |
|---|---|---|
$set |
{ $set: { name: 'Alice', age: 31 } } |
Setea campos |
$unset |
{ $unset: { tempField: 1 } } |
Elimina campos |
$inc |
{ $inc: { visits: 1, score: -5 } } |
Incrementa/decrementa |
$push |
{ $push: { tags: 'new-tag' } } |
Agrega a array |
$pull |
{ $pull: { tags: 'old-tag' } } |
Remueve de array |
$rename |
{ $rename: { oldName: 'newName' } } |
Renombra campo |
users.createIndex('email', { unique: true });
users.createIndex('category');
// Las queries sobre campos indexados usan el indice automaticamente
users.findOne({ email: 'alice@test.com' }); // O(1) en vez de O(n)users.createIndex('age', { type: 'sorted' });
// Range queries usan binary search
users.find({ age: { $gte: 18, $lte: 65 } }).toArray();articles.createIndex('content', { type: 'text' });
// Las búsquedas usan el índice automáticamente (descarte de acentos, minúsculas y stopwords)
articles.find({ content: { $text: 'Node.js desarrollo' } }).toArray();
// Búsqueda en modo OR (por defecto es AND)
articles.find({ content: { $text: 'Javascript moderno', $mode: 'OR' } }).toArray();users.dropIndex('email');
users.getIndexes(); // [{ field, type, unique }]orders.aggregate()
.match({ status: 'completed' })
.lookup({ from: 'users', localField: 'userId', foreignField: '_id', as: 'user', single: true })
.group('user.name', {
total: { $sum: 'price' },
count: { $count: true },
avgPrice: { $avg: 'price' },
minPrice: { $min: 'price' },
maxPrice: { $max: 'price' },
})
.sort({ total: -1 })
.limit(10)
.toArray();| Stage | Descripcion |
|---|---|
.match(filter) |
Filtra documentos |
.lookup(opts) |
Join ordinario o recursivo transitivo (grafos usando { type: 'graphLookup', ... }) |
.group(field, accumulators) |
Agrupa y calcula agregados |
.sort(spec) |
Ordena (1 asc, -1 desc) |
.limit(n) |
Limita resultados |
.skip(n) |
Salta N resultados |
.project(spec) |
Incluye/excluye campos |
.unwind(field) |
Desdobla arrays en documentos individuales |
| Accumulator | Ejemplo | Resultado |
|---|---|---|
$count |
{ total: { $count: true } } |
Cantidad de docs en el grupo |
$sum |
{ revenue: { $sum: 'price' } } |
Suma del campo |
$avg |
{ avgAge: { $avg: 'age' } } |
Promedio |
$min |
{ cheapest: { $min: 'price' } } |
Minimo |
$max |
{ highest: { $max: 'price' } } |
Maximo |
$push |
{ names: { $push: 'name' } } |
Array con todos los valores |
$first |
{ first: { $first: 'name' } } |
Primer valor del grupo |
$last |
{ last: { $last: 'name' } } |
Ultimo valor del grupo |
// One-to-one (single: true → un objeto, no array)
orders.aggregate()
.lookup({ from: 'users', localField: 'userId', foreignField: '_id', as: 'user', single: true })
.toArray();
// order.user = { _id: 'u1', name: 'Alice', ... }
// One-to-many (default → array)
users.aggregate()
.lookup({ from: 'orders', localField: '_id', foreignField: 'userId', as: 'orders' })
.toArray();
// user.orders = [{ product: 'GPU', ... }, { product: 'RAM', ... }]
// Con filtro
users.aggregate()
.lookup({
from: 'orders',
localField: '_id',
foreignField: 'userId',
as: 'bigOrders',
filter: { price: { $gt: 100 } }
})
.toArray();
// Double lookup (orders + user + product)
orders.aggregate()
.lookup({ from: 'users', localField: 'userId', foreignField: '_id', as: 'user', single: true })
.lookup({ from: 'products', localField: 'productId', foreignField: '_id', as: 'product', single: true })
.match({ 'product.category': 'hardware' })
.toArray();Permite recorrer estructuras jerárquicas o transitivas (árboles, redes, dependencias) resolviendo ciclos y bucles de forma automatizada:
// Encontrar toda la red de dependencias conectada a un servidor principal
const dependencias = nodes.aggregate()
.match({ _id: 'servidor-principal' })
.lookup({
type: 'graphLookup',
from: 'edges', // Colección donde buscar relaciones
startWith: '_id', // Campo semilla inicial
connectFromField: 'to', // Campo destino de la relación
connectToField: 'from', // Campo origen de la relación
as: 'redRecursivaCompleta', // Nombre del array resultado
maxDepth: 3 // Limitar a 3 niveles de profundidad
})
.toArray();js-doc-store incorpora una clase auxiliar GraphEngine para realizar recorridos de red e identificar caminos de forma ultra-rápida utilizando los mapas indexados en memoria:
const { GraphEngine } = require('js-doc-store');
const graph = new GraphEngine(db, 'nodes', 'edges');
// 1. Obtener vecinos directos (salientes, entrantes o ambos)
const vecinos = graph.getNeighbors('nodo-a', 'out', 'LAN');
// 2. BFS: Calcular el camino más corto entre dos nodos (retorna array de IDs)
const rutaOptima = graph.shortestPathBFS('nodo-a', 'nodo-d'); // ['A', 'B', 'C', 'D']
// 3. DFS: Encontrar todos los caminos posibles con control cíclico automático
const todosLosCaminos = graph.findAllPaths('nodo-a', 'nodo-d', 5);const adapter = await EncryptedAdapter.create(
new FileStorageAdapter('./data'),
'my-password'
);
const db = new DocStore(adapter);
// Todo se encripta con AES-256-GCM automaticamente
db.flush();
await adapter.persist(); // escribe encriptado a disco
// Para leer: preload primero
const adapter2 = await EncryptedAdapter.create(innerAdapter, 'my-password');
await adapter2.preload(['users.docs.json', 'users.meta.json']);
const db2 = new DocStore(adapter2);const fc = await FieldCrypto.create('my-password');
users.insert({
name: 'Alice', // queryable, indexable
city: 'Madrid', // queryable, indexable
ssn: await fc.encrypt('123-45-6789'), // encriptado
creditCard: await fc.encrypt('4111-...'), // encriptado
});
// Leer campo encriptado
const doc = users.findOne({ name: 'Alice' });
const ssn = await fc.decrypt(doc.ssn); // '123-45-6789'
// Verificar si esta encriptado
fc.isEncrypted(doc.ssn); // true
fc.isEncrypted(doc.name); // falseconst auth = new Auth(db, { secret: 'jwt-secret-key' });
await auth.init();const auth = new Auth(db, {
secret: 'jwt-secret',
passwordPolicy: {
minLength: 12, // default: 6
maxLength: 128, // default: sin limite
requireUppercase: true, // default: false
requireLowercase: true, // default: false
requireDigit: true, // default: false
requireSymbol: true, // default: false
customValidator: (pw) => { // default: undefined
if (pw.includes('password')) return 'No puede contener "password"';
return null; // null = OK
},
},
});La politica se aplica en register(), changePassword() y resetPassword(). Por default solo valida minLength: 6 (backward compatible).
Por default, register() valida el formato del email con /^[^\s@]+@[^\s@]+\.[^\s@]+$/. Para desactivarlo (ej: usar usernames en vez de emails):
const auth = new Auth(db, { secret: '...', validateEmail: false });const user = await auth.register('alice@test.com', 'password123', { name: 'Alice' });
const { token, user } = await auth.login('alice@test.com', 'password123');const payload = await auth.verify(token);
// { sub: 'user-id', email: 'alice@test.com', roles: ['user'], exp: ... }
// null si invalido o expiradoauth.assignRole(userId, 'admin');
auth.removeRole(userId, 'admin');
auth.hasRole(userId, 'admin');
// Verificar token + rol en una llamada
const payload = await auth.authorize(token, 'admin');
// payload si autorizado, null si noauth.getUser(userId);
auth.getUserByEmail('alice@test.com');
auth.listUsers({ roles: { $contains: 'admin' } }, { sort: { createdAt: -1 }, limit: 10 });
auth.disableUser(userId); // no puede hacer login
auth.enableUser(userId);
auth.deleteUser(userId); // elimina user + sessionsawait auth.changePassword(userId, 'old-pass', 'new-pass');
await auth.resetPassword(userId, 'new-pass'); // admin/recovery
auth.logout(token); // invalida sesion
auth.logoutAll(userId); // invalida todas las sesiones
auth.cleanExpiredSessions(); // limpieza// Node.js (disco)
new DocStore('./data');
new DocStore(new FileStorageAdapter('./data'));
// Memoria (tests, browser)
new DocStore(new MemoryStorageAdapter());
// Cloudflare Workers KV
const adapter = new CloudflareKVAdapter(env.MY_KV, 'prefix/');
await adapter.preloadAll(); // carga todos los archivos bajo el prefix
new DocStore(adapter);
// despues: db.flush(); await adapter.persist();
// O preload selectivo (si sabes que archivos necesitas):
// await adapter.preload(['users.docs.json', 'users.meta.json']);
// Listar keys disponibles:
// const keys = await adapter.listKeys(); // ['users.docs.json', 'users.meta.json', ...]
// Encriptado (wraps any adapter)
const adapter = await EncryptedAdapter.create(innerAdapter, 'password');
new DocStore(adapter);
// Git (wraps any adapter con dir; auto-commitea los cambios a un repo git)
const adapter = new GitStorageAdapter(new FileStorageAdapter('./data'), {
repoPath: '.', // raíz del repo (default: el dir del inner, o '.')
commitMessage: 'data update', // mensaje de cada commit
authorName: 'js-doc-store', // se pasan como git -c user.name/email (no shell)
authorEmail: 'bot@local',
batchIntervalMs: 0, // >0 agrupa escrituras en un commit cada N ms
autoPush: false, // push a pushRemote/pushBranch tras commitear
pushRemote: 'origin', pushBranch: 'master',
ignoreBin: false, // añade *.bin/*.vec al .gitignore
onError: null, // callback(err) para errores de git
});
new DocStore(adapter);
// Custom (implementar 3 metodos):
class MyAdapter {
readJson(filename) { /* → object | null */ }
writeJson(filename, data) { /* object → void */ }
delete(filename) { /* void */ }
}require('js-doc-store') también exporta utilidades: matchFilter(doc, filter),
applyUpdate(doc, update) y generateId() (las primitivas que usan internamente las
queries, los updates y la generación de _id).
Coleccion "users"
├── users.docs.json Documentos: [{ _id, name, age, ... }]
├── users.meta.json Metadata: { indexes: [{ field, type, unique }] }
├── users.email.idx.json Hash index (si existe)
└── users.age.sidx.json Sorted index (si existe)
| SQL | js-doc-store |
|---|---|
SELECT * FROM users WHERE age > 18 |
users.find({ age: { $gt: 18 } }).toArray() |
SELECT name, age FROM users ORDER BY age DESC LIMIT 10 |
users.find({}).sort({ age: -1 }).limit(10).project({ name: 1, age: 1 }).toArray() |
SELECT COUNT(*) FROM users WHERE city = 'Madrid' |
users.count({ city: 'Madrid' }) |
UPDATE users SET age = 31 WHERE email = 'x' |
users.update({ email: 'x' }, { $set: { age: 31 } }) |
DELETE FROM users WHERE status = 'inactive' |
users.removeMany({ status: 'inactive' }) |
SELECT u.name, SUM(o.price) FROM orders o JOIN users u ON o.userId = u._id GROUP BY u.name |
orders.aggregate().lookup({ from: 'users', localField: 'userId', foreignField: '_id', as: 'user', single: true }).group('user.name', { total: { $sum: 'price' } }).toArray() |
CREATE UNIQUE INDEX idx ON users(email) |
users.createIndex('email', { unique: true }) |
Resultados en Node.js, N=10,000 documentos:
| Operacion | Latencia | Notas |
|---|---|---|
| findOne (hash index) | 29us | O(1) lookup |
| hash lookup + limit 10 | 1.06ms | Indice + clone solo top 10 |
| range index + limit 10 | 2.42ms | Binary search + limit |
| full scan + limit 10 | 1.45ms | Sin indice, early limit |
| sort indexed + limit 10 | 4.81ms | SortedIndex, sin sort en memoria |
| sort in-memory + limit 10 | 5.15ms | Fallback sin indice |
| count (con filtro) | 1.36ms | Sin allocations |
| Operacion | Latencia |
|---|---|
| insert | 47us/doc |
| update ($inc) | 278us/op |
| flush (10K docs) | 424us |
| N docs | insert total | findOne | scan + limit 10 |
|---|---|---|---|
| 100 | 7ms | 22us | 757us |
| 1,000 | 52ms | 16us | 6.85ms |
| 5,000 | 155ms | 18us | 29.9ms |
| 10,000 | 470ms | 27us | 61.2ms |
| 50,000 | 4.24s | 101us | 326ms |
- structuredClone cuando disponible, JSON fallback
- Clone solo en frontera — operaciones internas trabajan con refs raw
- Skip+limit antes de clone — solo clona los N resultados finales
- SortedIndex en cursor.sort() — evita sort en memoria cuando hay indice
- _countMatching — cuenta sin allocar array de resultados
- Dirty tracking —
_dirtyIdspara flush incremental
| Aspecto | js-doc-store | D1 |
|---|---|---|
| Costo por query | $0 (CPU del Worker) | $0.001/M rows read |
| Costo por write | $0 (flush a KV) | $1.00/M rows written |
| Storage | KV: $0.50/GB | $0.75/GB |
| Max docs | ~100K (limite memoria) | Millones |
| SQL | No (queries MongoDB-style) | Si (SQLite completo) |
| Joins | lookup() en aggregation | JOIN nativo |
| ACID | No (eventual consistency) | Si |
| Portabilidad | Node/browser/Workers/Deno | Solo Cloudflare |
| Offline | Si | No |
| Encriptacion | AES-256-GCM built-in | No |
| Auth | JWT + RBAC built-in | No |
Recomendacion: js-doc-store para < 100K docs con portabilidad, encriptacion, o auth integrado. D1 para datasets grandes con SQL complejo.
Capa estilo Airtable sobre DocStore: columnas tipadas, validacion, defaults, autonumber, vistas guardadas, y templates.
const { DocStore, MemoryStorageAdapter, Table } = require('./js-doc-store');
const db = new DocStore(new MemoryStorageAdapter());
const contacts = new Table(db, 'contacts', {
columns: [
{ name: 'Name', type: 'text', required: true },
{ name: 'Email', type: 'email', unique: true },
{ name: 'Phone', type: 'phone' },
{ name: 'Age', type: 'number' },
{ name: 'Active', type: 'checkbox', default: true },
{ name: 'Status', type: 'select', options: ['Lead', 'Active', 'Churned'] },
{ name: 'Tags', type: 'multiselect', options: ['VIP', 'Enterprise', 'SMB'] },
{ name: 'Website', type: 'url' },
{ name: 'Company', type: 'relation', collection: 'companies' },
{ name: 'Number', type: 'autonumber' },
]
});
// Insert valida tipos, required, opciones, y unicidad
contacts.insert({ Name: 'Alice', Email: 'alice@test.com', Status: 'Lead' });
// → { _id: '...', Name: 'Alice', Active: true, Number: 1, ... }| Tipo | Validacion | Ejemplo |
|---|---|---|
text |
string | 'Hello' |
number |
number, no NaN | 42 |
checkbox |
boolean | true |
date |
string, number, o Date | '2024-01-15' |
email |
formato email | 'alice@test.com' |
url |
comienza con http(s):// | 'https://example.com' |
phone |
digitos, espacios, +, -, () | '+1 555-1234' |
select |
valor en options[] |
'Active' |
multiselect |
array de valores en options[] |
['VIP', 'Enterprise'] |
relation |
_id de doc en otra coleccion | 'co-1' |
json |
cualquier valor | { key: 'value' } |
attachment |
string (URL) u objeto | 'https://...' |
autonumber |
auto-incrementa (1, 2, 3...) | no se pasa en insert |
formula |
campo computado | no se valida |
| Opcion | Efecto |
|---|---|
required: true |
No puede ser undefined/null/vacio |
unique: true |
Crea indice unico automaticamente |
default: value |
Valor por defecto (o funcion: () => Date.now()) |
options: [...] |
Opciones validas para select/multiselect |
collection: 'name' |
Coleccion relacionada (para type: 'relation') |
// Crear vista
contacts.createView('active-vip', {
filter: { $and: [{ Status: 'Active' }, { Tags: { $contains: 'VIP' } }] },
sort: { Name: 1 },
limit: 50,
});
// Ejecutar vista
const results = contacts.view('active-vip');
// Gestionar vistas
contacts.listViews(); // ['active-vip']
contacts.getView('active-vip'); // { filter, sort, limit }
contacts.dropView('active-vip');contacts.getSchema(); // { name, columns: [...] }
contacts.addColumn({ name: 'Score', type: 'number', default: 0 });
contacts.renameColumn('Score', 'Rating'); // renombra en todos los docs
contacts.removeColumn('Rating');// Expandir relacion: reemplaza _id con el documento completo
const doc = contacts.findById(id);
const expanded = contacts.expandRelations(doc);
// doc.Company = 'co-1' → expanded.Company = { _id: 'co-1', name: 'Acme', ... }const { createFromTemplate } = require('./js-doc-store');
const crm = createFromTemplate(db, 'my-crm', 'crm');
const tasks = createFromTemplate(db, 'my-tasks', 'tasks');
const inv = createFromTemplate(db, 'my-inv', 'inventory');
const blog = createFromTemplate(db, 'my-blog', 'content');| Template | Columnas incluidas |
|---|---|
crm |
Name, Email, Phone, Company, Status (Lead/Qualified/Active/Churned), Revenue, Notes, Tags, CreatedAt |
tasks |
Title, Description, Status (Todo/In Progress/Done/Blocked), Priority (Low-Urgent), Assignee, DueDate, Tags, Number, CreatedAt |
inventory |
SKU (unique), Name, Category, Price, Stock, Active, ImageURL, Number |
content |
Title, Body, Author, Status (Draft/Review/Published/Archived), Category, Tags, PublishedAt, URL, Number, CreatedAt |
js-doc-store forma parte de un stack completo para agentes LLM:
| Paquete | npm | Descripcion |
|---|---|---|
| js-doc-store | Document database (este paquete) | |
| js-vector-store | Vector database para busqueda semantica | |
| just-bash-data | Plugin just-bash: expone db y vec como comandos shell para agentes LLM |
|
| just-bash-wiki | Wiki persistente mantenido por LLMs (Karpathy's LLM Wiki pattern) |
LLM Agent (Claude, GPT, Gemma, etc.)
│ tool_use
▼
just-bash (sandboxed bash interpreter)
│
├── just-bash-wiki → wiki command (init, source, page, search, lint)
│ │
└── just-bash-data → db command + vec command
│ │
├── js-doc-store └── js-vector-store
│ (CRUD, queries, (embeddings, cosine,
│ indexes, auth, IVF, Matryoshka,
│ encryption) quantization)
│
└── Storage Adapters (Memory, File, KV, Encrypted)
Además del stack de agentes (ver Ecosistema arriba), js-doc-store es la base de una cadena de librerías embebidas doc+vector:
- js-store — base de datos embebida doc+vector que vendoriza este core y le suma búsqueda vectorial, IVF y persistencia en disco.
- js-base — backend embebido estilo PocketBase construido sobre js-store (que a su vez usa este core).
Creado por Mauricio Perera
MIT