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

endee

v2.0.0

Published

TypeScript client for encrypted vector database with maximum security and speed

Readme

Endee — TypeScript / JavaScript Client

Endee is a high-performance C++ vector database for AI search, RAG, and hybrid retrieval. A collection holds objects, and each object can carry values for several named, typed fields at once — dense vectors, sparse vectors, and multi-vectors — so a single object can be searched many ways.

  • Multi-field objects — one object, many vector fields (dense + sparse + multi-vector).
  • Hybrid search — query any subset of fields; search() returns per-field results, which you can fuse with rerank() (RRF).
  • Client-side normalization — cosine vectors are L2-normalized for you.
  • Full control plane — collections, objects, filters, rebuild/shrink, backups, databases, and tokens.
  • TypeScript first — full type definitions and IntelliSense for every call.
  • ES modules — native ESM with proper tree-shaking.

Table of contents

  1. Install
  2. Quick start
  3. Connecting & tokens
  4. Create a collection
  5. Insert objects (combining vector types)
  6. Search
  7. Object operations
  8. Collection maintenance
  9. Backups
  10. Database administration (root token)
  11. Reference
  12. Error handling

Install

npm install endee

Requires Node.js ≥ 18. Depends on axios and @msgpack/msgpack.


Quick start

import { Endee } from 'endee';

// A "db token" (db_name:secret) scopes you to one database.
const client = new Endee('my_db:xxxxxxxx');
client.setBaseUrl('http://localhost:8080/api/v2'); // include /api/v2

// 1. Create a collection with one of each field type.
await client.createCollection({
  name: 'products',
  fields: [
    {
      name: 'embedding',
      type: 'vector',
      params: { dimension: 8, space_type: 'cosine', precision: 'int8' },
    },
    { name: 'keywords', type: 'sparse', sparse_model: 'default' },
    {
      name: 'colbert',
      type: 'multi_vector',
      params: { dimension: 8, space_type: 'cosine', precision: 'int8', pooling: 'mean' },
    },
  ],
});

const collection = await client.getCollection('products');

// 2. Insert an object carrying ALL THREE field types at once.
await collection.upsert([
  {
    id: 'p1',
    meta: { name: 'Wireless Headphones', price: 99 },
    filter: { category: 'electronics' },
    fields: {
      embedding: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
      keywords: { indices: [3, 17, 42], values: [0.9, 0.5, 0.2] },
      colbert: [Array(8).fill(0.1), Array(8).fill(0.2)],
    },
  },
]);

// 3. Search the dense field. `results` is keyed by field name.
const res = await collection.search({
  fields: { embedding: { query: Array(8).fill(0.2), limit: 5 } },
});
for (const h of res.results.embedding) {
  console.log(h.id, h.similarity, h.meta);
}

Connecting & tokens

Authentication is always required. The server has no anonymous mode — every request must carry a token, or it is rejected with 401 Unauthorized.

A db token has the form db_name:secret and scopes you to all collection / object / search work inside one database. (Database administration and minting tokens require the server's root token — those control-plane operations are not yet exposed in the JS client; see Reference.)

const client = new Endee('my_db:xxxxxxxx');
client.setBaseUrl('http://localhost:8080/api/v2'); // must point at the /api/v2 root
  • A token of the form db_name:secret:region is parsed automatically — the client targets https://<region>.endee.io/api/v2 and strips the region from the token it sends.
  • setBaseUrl must point at the /api/v2 root of your server.
  • A missing/invalid token → AuthenticationException (401); a read-only token on a write → ForbiddenException (403).

Create a collection

A collection is a set of named, typed fields. Each field is a plain object sent to the server as-is.

| Field type | Required keys | params | |------------|---------------|----------| | vector | name, type | dimension, space_type, precision (+ optional M, ef_con) | | sparse | name, type, sparse_model | — (no params) | | multi_vector | name, type | dimension, space_type, precision, pooling ("mean"/"max") (+ optional M, ef_con) |

await client.createCollection({
  name: 'products',
  fields: [
    // Dense embedding (single vector per object)
    {
      name: 'embedding',
      type: 'vector',
      params: { dimension: 768, space_type: 'cosine', precision: 'int8' },
    },

    // Sparse / keyword field (BM25-style)
    { name: 'keywords', type: 'sparse', sparse_model: 'default' },

    // Multi-vector field (many vectors per object)
    {
      name: 'colbert',
      type: 'multi_vector',
      params: { dimension: 768, space_type: 'cosine', precision: 'int8', pooling: 'mean' },
    },
  ],
});
  • space_type: cosine (default), l2, ip.
  • precision: float32, float16, int16 (default), int8, int8e, binary.
  • M / ef_con: HNSW build params (optional; sensible defaults applied).
  • Cosine vectors are L2-normalized by the client before sending.

Other client-level collection calls:

await client.listCollections(); // [{ name: "products", ... }, ...]
const collection = await client.getCollection('products');
await collection.describe(); // { name, fields, created_at, layout_version }
await client.deleteCollection('products');

Insert objects (combining vector types)

collection.upsert(objects) takes an array of objects. Each object is:

{
  id: '<unique string id>',
  meta?: { /* ... */ },     // arbitrary JSON, returned in search results (optional)
  filter?: { /* ... */ },   // tag object used by filtered search (optional)
  fields?: {                // field_name -> value, ONE entry per field you set
    '<dense_field>':  [/* float, ... */],                       // dense  → array
    '<sparse_field>': { indices: [/* ... */], values: [/* ... */] }, // sparse → object
    '<multi_field>':  [[/* float, ... */], [/* float, ... */]], // multi  → array of arrays
  },
}

Per-field value formats:

| Field type | Value format | |------------|--------------| | vector | [0.1, 0.2, ...] — a flat array of floats | | sparse | { indices: [3, 17], values: [0.9, 0.5] } (also accepts sparse_indices/sparse_values) | | multi_vector | [[...], [...], ...] — an array of equal-length float arrays |

One object, multiple field types

A single object can populate any combination of the collection's fields in one call. They're stored together under the same id and can be searched independently or together:

await collection.upsert([
  {
    id: 'p1',
    meta: { name: 'Wireless Headphones', price: 99 },
    filter: { category: 'electronics' },
    fields: {
      embedding: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], // dense
      keywords: { indices: [3, 17, 42], values: [0.9, 0.5, 0.2] }, // sparse
      colbert: [
        // multi-vector
        [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
        [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9],
      ],
    },
  },
  {
    id: 'p2',
    meta: { name: 'Running Shoes' },
    filter: { category: 'footwear' },
    fields: {
      // An object may set only SOME fields — here just dense + sparse.
      embedding: [0.5, 0.4, 0.3, 0.2, 0.1, 0.05, 0.02, 0.01],
      keywords: { indices: [5, 17, 90], values: [0.7, 0.6, 0.1] },
    },
  },
]);
// → { upserted: 2 }

Notes:

  • You don't have to fill every field on every object — set whatever subset you have.
  • upsert is insert-or-replace by id.
  • Cosine vector/multi_vector values are normalized client-side before sending; the original norm is stored so getObjects returns the original (pre-normalization) vectors.
  • Validation is done client-side (fails fast): batch size ≤ 10,000, no duplicate ids in a batch, dense/multi dims must match the field, sparse indices/values lengths must match, filter keys ≤ 128 bytes / string values ≤ 1024 bytes.

Search

collection.search({ fields, filter }) queries one or more fields in a single request and always returns one ranked list per field, keyed by field name. To collapse those lists into a single ranked list, fuse them with rerank().

The unified field shape

Every field is queried with the same object form{ query, limit?, ef_search? }. There is no bare-value shorthand; query is required. The query value matches the field's type:

| Field type | query shape | |------------|-------------| | vector | [0.2, 0.2, ...] | | sparse | { indices: [3, 17], values: [0.8, 0.4] } | | multi_vector | [[...], [...]] |

Each result hit is:

{ id: 'p1', similarity: 0.94, meta: { /* ... */ }, filter: { /* ... */ } }

Search results carry meta/filter but not the stored vectors. To get the vectors back, use getObjects.

Per-field limit and ef_search

limit is the max number of hits to return for that field (defaults to 10). It's set per field, so each field draws independently. ef_search tunes that field's HNSW recall/latency.

// embedding returns up to 35 hits, keywords up to 15 — each independent
await collection.search({
  fields: {
    embedding: { query: Array(8).fill(0.2), limit: 35, ef_search: 256 },
    keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 15 },
  },
});

Single-field search

A single field is just the multi-field shape with one entry. results is keyed by that field name:

const res = await collection.search({
  fields: { embedding: { query: Array(8).fill(0.2), limit: 3 } },
});
res.results.embedding;
// [
//   { id: "p1", similarity: 0.97, meta: {...}, filter: {...} },
//   { id: "p2", similarity: 0.88, meta: {...}, filter: {...} },
// ]

// sparse
await collection.search({
  fields: { keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 3 } },
});

// multi-vector
await collection.search({
  fields: { colbert: { query: [Array(8).fill(0.1), Array(8).fill(0.2)], limit: 3 } },
});

Multi-field search

Query several fields and you get each field's own ranked list, unfusedresults is an object keyed by field name. Merge or display them however you like, or hand them to rerank().

const res = await collection.search({
  fields: {
    embedding: { query: [0.2, 0.2, 0.3, 0.3], limit: 2 },
    keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 2 },
  },
});
{
  "results": {
    "embedding": [
      { "id": "p1", "meta": { "name": "Wireless Headphones" },
        "filter": { "category": "electronics" }, "similarity": 0.4938 },
      { "id": "p2", "meta": { "name": "Running Shoes" },
        "filter": { "category": "footwear" }, "similarity": 0.4505 }
    ],
    "keywords": [
      { "id": "p1", "meta": { "name": "Wireless Headphones" },
        "filter": { "category": "electronics" }, "similarity": 0.9200 },
      { "id": "p2", "meta": { "name": "Running Shoes" },
        "filter": { "category": "footwear" }, "similarity": 0.5591 }
    ]
  }
}
res.results.embedding; // this field's ranked hits
res.results.keywords; // that field's ranked hits

similarity is in each field's own scale (cosine for dense ≈ 0.49, sparse dot for keywords ≈ 0.92) — scores are not comparable across fields. That's exactly why fusion is a separate, opt-in step.

Fusing results with rerank()

rerank({ searchResults, limit, fieldWeights, rrfK }) fuses the per-field lists from search() into one ranked list using Reciprocal Rank Fusion. It's a standalone function — run search(), then pass its result to rerank():

import { rerank } from 'endee';

const res = await collection.search({
  fields: {
    embedding: { query: [0.2, 0.2, 0.3, 0.3], limit: 50 },
    keywords: { query: { indices: [3, 17], values: [0.8, 0.4] }, limit: 50 },
    colbert: { query: [Array(8).fill(0.1), Array(8).fill(0.2)], limit: 50 },
  },
});

const fused = rerank({
  searchResults: res,
  limit: 10,
  // optional: weight each field's contribution (must sum to 1.0; equal by default)
  fieldWeights: { embedding: 0.5, keywords: 0.3, colbert: 0.2 },
  // optional: RRF rank constant k (default 60)
  rrfK: 60,
});
{
  "results": [
    { "id": "p1", "similarity": 0.0164,
      "meta": { "name": "Wireless Headphones" }, "filter": { "category": "electronics" } },
    { "id": "p3", "similarity": 0.0160,
      "meta": { "name": "Smart Watch" }, "filter": { "category": "electronics" } },
    { "id": "p2", "similarity": 0.0160,
      "meta": { "name": "Running Shoes" }, "filter": { "category": "footwear" } }
  ]
}

searchResults is the response from search(). rrfK is the RRF rank constant (default 60); a larger value flattens the contribution of top ranks, a smaller value sharpens it. Each fused hit keeps the meta/filter from its per-field hit; similarity is replaced with the RRF score (sum of weight / (rrfK + rank) across the fields it appeared in).

Summary of return shapes

| Call | results shape | |------|-----------------| | search(...) (1 or N fields) | Record<field_name, SearchHit[]> | | rerank({ searchResults, ... }) | SearchHit[] (fused) |

Filtered search

filter applies to any search. It's an array of conditions:

await collection.search({
  fields: { embedding: { query: Array(8).fill(0.2), limit: 5 } },
  filter: [{ category: { $eq: 'electronics' } }],
});

Operators: $eq, $in, $range, $gt, $gte, $lt, $lte (see Reference).

Filter tuning

Two optional params tune the speed/recall trade-off of filtered queries. They're sent to the server under filter_params and ignored on unfiltered searches.

await collection.search({
  fields: { embedding: { query: Array(8).fill(0.2), limit: 10 } },
  filter: [{ category: { $eq: 'rare' } }],
  prefilterCardinalityThreshold: 5000, // default 10,000; range 1,000–1,000,000
  filterBoostPercentage: 25, // default 0; max 100
});

prefilterCardinalityThreshold — the cardinality below which the search switches from HNSW filtered search to brute-force prefiltering. When very few vectors match the filter, scanning the matched subset directly is faster and more accurate than HNSW graph traversal. Raising it makes prefiltering kick in more often (favors the exhaustive scan); lowering it favors HNSW graph search (favors speed on large datasets).

filterBoostPercentage — in HNSW filtered search, candidates that fail the filter are discarded, which can leave fewer than limit results. This expands the internal candidate pool before filtering to compensate. 0 = no boost, 100 = doubles the pool.


Object operations

// Fetch full stored objects by id (meta, filter, and the stored vectors).
const objs = await collection.getObjects(['p1', 'p2']);
// [
//   { id: "p1", meta: {...}, filter: {...},
//     vectors: { embedding: [...] },               // dense fields (original, pre-normalization)
//     sparses: { keywords: { indices: [...], values: [...] } },
//     multi_vectors: { colbert: [[...], [...]] } },
//   ...
// ]

// Inspect an object's base-layer (level 0) HNSW links for one dense field.
// `field` must be a dense ANN field (a `vector` field, or a `multi_vector`
// field's pooled index); sparse fields have no HNSW graph. These are the
// graph's pruned, diversity-selected edges — for actual nearest neighbours
// use `search`.
await collection.getNeighborsById('p1', 'embedding');
// { id: "p1", field: "embedding", links: ["p2", "p3"] }

// Delete a single object by id.
await collection.deleteObject('p1'); // { deleted: "p1" }

// Delete every object matching a filter.
await collection.deleteByFilter([{ category: { $eq: 'footwear' } }]); // { deleted: <count> }

// Update only the filter tags on existing objects (no re-upsert of vectors).
await collection.updateFilters([{ id: 'p2', filter: { category: 'sale' } }]); // { updated: <count> }

Collection maintenance

await collection.describe(); // { name, fields, created_at, layout_version }

// Rebuild one or more dense fields' HNSW graphs in a single async call.
// Pass a list of field specs; only M/ef_con may change. Sparse fields are skipped server-side.
await collection.rebuild([
  { field: 'embedding', m: 20, efCon: 200 },
  { field: 'colbert', m: 12, efCon: 120 },
]); // { fields_total: 2, total_objects: ..., status: "in_progress" }

// Poll progress; reports the latest rebuilt field plus overall counts.
// Per-database status (one rebuild at a time), like backupStatus/restoreStatus.
await collection.rebuildStatus(); // { field, fields_done, fields_total, percent_complete, status, ... }

// Defragment storage in place.
await collection.shrink(); // { status: "ok", reclaimed_bytes: <int> }

Backups

Backups are per-database. Creating one is per-collection and asynchronous.

// Start a backup of a collection (async).
await collection.createBackup('nightly'); // { backup_name: "nightly", status: "in_progress" }

// Poll the job to completion; status is idle/running/completed/failed.
let status = await client.backupStatus(); // { status, phase, percent_complete, error_message, ... }
while (status.status === 'running') {
  // ...wait, then re-check
  status = await client.backupStatus();
}

await client.listBackups(); // { nightly: { ...metadata }, ... }
await client.backupInfo('nightly'); // metadata for one backup

// Restore a backup into a new collection (async).
await client.restoreBackup('nightly', 'products_restored'); // { status: "running" }
await client.restoreStatus(); // poll like backupStatus() until it finishes

// Move backups around.
await client.downloadBackup('nightly', '/tmp/nightly.tar'); // writes a .tar, returns the path
await client.downloadBackup('nightly', '/tmp/'); // a directory gets /tmp/nightly.tar
await client.uploadBackup('/tmp/nightly.tar'); // streams the .tar in as backup "nightly"
await client.uploadBackup('/tmp/nightly.tar', 'nightly_copy'); // ...or under another name

await client.deleteBackup('nightly');

downloadBackup / uploadBackup use Node's filesystem (fs); the upload streams the raw tar as the request body. With the root token, pass a third dbName arg to downloadBackup to target a specific database's backup.


Database administration (root token)

Database and token administration requires the server's root token (its NDD_ROOT_TOKEN). Create an admin client with that token:

const admin = new Endee('roottoken');
admin.setBaseUrl('http://localhost:8080/api/v2');

The root token administers databases/tokens; it cannot run data ops directly. A db token (which it mints) does the collection/object/search work.

Databases

// Create a database → returns the new db_token.
const res = await admin.createDatabase('my_db', 'scale'); // db_type: starter|pro|scale|enterprise
const dbToken = res.db_token;

await admin.listDatabases(); // [{ db_name, db_type, is_active, created_at }, ...]
await admin.getDatabase('my_db'); // single db info

await admin.setDatabaseType('my_db', 'pro'); // change tier
await admin.deactivateDatabase('my_db'); // block its tokens (data is kept)
await admin.activateDatabase('my_db'); // re-enable
await admin.deleteDatabase('my_db'); // delete db + ALL its data

Collections across databases (admin view)

await admin.listDbCollections('my_db'); // ["products", ...] for one db
await admin.listAllCollections(); // [{ db_name: "my_db", collections: [...] }, ...]
await admin.deleteDbCollection('my_db', 'products');

Tokens for any database

// token_type: "rw" (read-write, default) or "r" (read-only).
const t = await admin.createToken('my_db', 'ci-reader', 'r'); // { db_token, ... }
await admin.listTokens('my_db'); // [{ name, token_type, created_at }, ...] (no secrets)
await admin.deleteToken('my_db', 'ci-reader');

Self-service tokens (your own database)

Using a db token, you can manage your own database's tokens:

await client.createMyToken('reader', 'r'); // { db_token, ... }
await client.listMyTokens();
await client.deleteMyToken('reader');

Server info

await client.health(); // { status, timestamp }
await client.stats(); // { version, uptime, total_requests }

Reference

Distance / space types (space_type)

| Value | Meaning | |-------|---------| | cosine | Cosine similarity (vectors L2-normalized client-side). Default. | | l2 | Euclidean distance. | | ip | Inner product. |

Precisions (precision) — memory/accuracy trade-off

float32, float16, int16 (default), int8, int8e, binary. The Precision enum is exported for convenience:

import { Precision } from 'endee';
Precision.INT8; // "int8"

Token types: rw (read-write), r (read-only).

Filter operators

| Operator | Example | Meaning | |----------|---------|---------| | $eq | { category: { $eq: 'news' } } | equals | | $in | { tag: { $in: ['a', 'b'] } } | in set | | $range | { price: { $range: [10, 100] } } | inclusive range | | $gt / $gte | { price: { $gt: 50 } } | greater than / or equal | | $lt / $lte | { price: { $lte: 100 } } | less than / or equal |

Client-side validation / limits

| Limit | Value | |-------|-------| | Objects per upsert | ≤ 10,000 | | limit (top-k) | 1 … 4096 | | efSearch | 1 … 1024 | | Filter key | ≤ 128 bytes | | Filter string value | ≤ 1024 bytes | | Vector dimension | must match the field's configured dimension |

Exported TypeScript types

import type {
  FieldDefinition,
  FieldType,
  ObjectInput,
  SearchOptions,
  SearchHit,
  SearchResponse,
  RerankOptions,
  RerankResponse,
  FullObject,
  CollectionMetadata,
  UpdateFilterEntry,
  RebuildFieldSpec,
  BackupJobStatus,
  DatabaseInfo,
  TokenInfo,
  DbType,
  TokenType,
  SpaceType,
  Precision,
} from 'endee';

Error handling

Non-2xx responses throw typed exceptions:

import {
  EndeeException, // base class for all client errors
  APIException, // generic 4xx (e.g. bad request)
  AuthenticationException, // 401
  ForbiddenException, // 403 (e.g. read-only token on a write)
  NotFoundException, // 404
  ConflictException, // 409 (e.g. duplicate collection)
  ServerException, // 5xx
} from 'endee';

try {
  await collection.upsert(objects);
} catch (e) {
  if (e instanceof NotFoundException) {
    console.error('not found:', e.message);
  } else if (e instanceof EndeeException) {
    // catches all of the above
    console.error('request failed:', e.message);
  } else {
    throw e;
  }
}

All exception classes are importable from endee: EndeeException (base), APIException, AuthenticationException, ForbiddenException, NotFoundException, ConflictException, ServerException, SubscriptionException.

Client-side input mistakes (bad dimensions, mismatched sparse lengths, oversized batch, sum-of-weights ≠ 1.0, etc.) throw a plain Error before any network call.


End-to-end example

See tests/integration_v2.ts — a full walkthrough (create → multi-field upsert → every search mode → object ops → maintenance → cleanup). Run it against a local server with a db token:

npx tsx tests/integration_v2.ts --token my_db:xxxx --url http://localhost:8080/api/v2

License

MIT