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

searchlite-js

v0.2.5

Published

A fast full-text search engine for Node.js with two index backends:

Downloads

896

Readme

searchlite-js

A fast full-text search engine for Node.js with two index backends:

  • EmbeddedIndex — native Rust bindings via napi-rs. No external services, no network calls, no setup.
  • RemoteIndex — HTTP client for a remote searchlite-http server. Query indexes that live on another machine.

Both implement the same async SearchIndex interface, so you can swap backends without changing application code.

const { EmbeddedIndex } = require('searchlite-js');

const index = new EmbeddedIndex('./my-index', {
  schema: { title: 'text', body: 'text', tag: 'keyword' },
});

await index.add({ _id: '1', title: 'Getting Started', body: 'Hello, world!', tag: 'intro' });
await index.add({ _id: '2', title: 'Advanced Search', body: 'Filters, facets, and more', tag: 'guide' });
await index.commit();

const results = await index.search('hello');
console.log(results.hits[0].docId); // "1"

await index.close();

Or connect to a remote server:

const { RemoteIndex } = require('searchlite-js');

const index = new RemoteIndex('http://localhost:8080', 'my-index');
const results = await index.search('hello');

Installation

npm install searchlite-js

Prebuilt binaries are available for:

| Platform | Architectures | |----------|---------------| | macOS | x64, arm64 | | Linux | x64, arm64 | | Windows | x64 |

Quick Start

1. Define a Schema

Every index has a schema that describes what fields your documents contain. Use shorthand strings for common configurations:

const index = new EmbeddedIndex('./products', {
  schema: {
    name: 'text',       // full-text searchable, stored
    description: 'text',
    brand: 'keyword',   // exact match, filterable, fast
    price: 'float',     // numeric, fast field for range filters
    year: 'integer',    // integer, fast field
  },
});

Or pass a full JSON Schema with searchlite: vocabulary keywords for complete control:

const index = new EmbeddedIndex('./products', {
  schema: {
    type: 'object',
    properties: {
      name: { type: 'string' },
      brand: { type: 'string', 'searchlite:kind': 'keyword' },
      price: { type: 'number', 'searchlite:stored': true },
    },
  },
});

Or, if you prefer a single schema for indexing and runtime validation and TypeScript types, use the Zod-native authoring path:

import { z } from 'zod';
import { EmbeddedIndex, sl } from 'searchlite-js';

const ProductSchema = sl.index(
  z.object({
    id: z.string().uuid(),              // auto-promoted to keyword
    name: z.string(),                    // text (full-text)
    brand: sl.keyword(),                 // keyword (exact match, fast)
    price: sl.float({ stored: true }),
    year: sl.integer({ stored: true }),
  }),
  { docIdField: 'id' },
);

type Product = z.infer<typeof ProductSchema>;

const index = new EmbeddedIndex<Product>('./products', { schema: ProductSchema });
await index.add({
  id: '550e8400-e29b-41d4-a716-446655440000',
  name: 'Wireless Headphones',
  brand: 'AudioCo',
  price: 79.99,
  year: 2024,
});
await index.commit();

const result = await index.search('wireless');
// result.hits[0].fields is typed as Product

With the Zod path:

  • add() / addMany() validate documents against the Zod schema before indexing.
  • search() results are validated and typed against the same schema — no need to pass it again.
  • z.infer<typeof Schema> gives you the document type for free.

All three forms (shorthand, raw JSON Schema, Zod) compile to the same internal representation and are fully interchangeable. Choose based on ergonomics:

  • Shorthand — fastest to author for simple flat schemas
  • Raw JSON Schema — interop with non-TS clients (sharable as a schema.json file)
  • Zod — single source of truth for indexing + validation + TS types; supports nested objects and arrays natively

See docs/zod-guide.md for the full Zod walkthrough, the type-mapping rules, and migration recipes.

2. Add Documents

await index.add({
  _id: 'product-1',
  name: 'Wireless Headphones',
  description: 'Noise-cancelling over-ear headphones',
  brand: 'AudioCo',
  price: 79.99,
  year: 2024,
});

// Or add many at once
const count = await index.addMany([
  { _id: 'product-2', name: 'USB Microphone', brand: 'SoundPro', price: 49.99, year: 2024 },
  { _id: 'product-3', name: 'Webcam HD', brand: 'VisionTech', price: 39.99, year: 2023 },
]);
console.log(count); // 2

3. Commit

Documents are queued in memory until you commit. This makes bulk indexing fast — commit once after adding a batch.

await index.commit();
// Now documents are searchable and durable on disk

4. Search

const results = await index.search('headphones');
console.log(results.totalHits);           // 1
console.log(results.hits[0].docId);       // "product-1"
console.log(results.hits[0].score);       // BM25 relevance score

Choosing an Index Type

| | EmbeddedIndex | RemoteIndex | |---|---|---| | Use when | Search runs in-process | Index lives on another server | | Latency | Microseconds (native) | Network round-trip | | Write support | Full (add, commit, compact) | Full (via HTTP API) | | Dependencies | Native binary (.node) | fetch (Node 20+) | | Constructor | new EmbeddedIndex(path, opts?) | new RemoteIndex(baseUrl, indexName, opts?) |

Both implement the SearchIndex interface — all methods return Promises.

API Reference

new EmbeddedIndex(path, options?)

Opens or creates an index at the given filesystem path.

// Create a new index (schema required)
const index = new EmbeddedIndex('./my-index', { schema: { title: 'text' } });

// Open an existing index
const index = new EmbeddedIndex('./my-index');

// With a write key for access control
const index = new EmbeddedIndex('./my-index', {
  schema: { title: 'text' },
  writeKey: 'my-secret-key',
});

Behavior:

| Schema provided? | Index exists? | Result | |---|---|---| | Yes | No | Creates the index | | Yes | Yes | Opens and validates schema matches | | No | Yes | Opens the index | | No | No | Throws an error |

If you provide a schema when reopening an existing index, it's validated against the on-disk schema. A mismatch throws an error — this prevents accidentally writing documents with the wrong field types.

index.add(doc)

Queues a single document for indexing. The _id field is used as the document identifier.

index.add({ _id: 'doc-1', title: 'Hello World', body: 'My first document' });

index.addMany(docs)

Queues multiple documents. Returns the number of documents queued.

const count = index.addMany([
  { _id: 'doc-1', title: 'First' },
  { _id: 'doc-2', title: 'Second' },
]);
// count === 2

index.commit()

Makes all queued documents durable and searchable. Until you call commit(), added documents won't appear in search results.

index.search(query)

Search with a simple string or a full request object.

String query — searches across all indexed text fields:

const results = index.search('wireless headphones');

Request object — full control over search behavior:

const results = index.search({
  query: 'wireless',
  limit: 20,
  returnStored: true,
  filter: { KeywordEq: { field: 'brand', value: 'AudioCo' } },
});

Returns a SearchResult:

{
  totalHits: 42,            // estimated total matching documents
  hits: [
    {
      docId: 'product-1',   // document ID
      score: 1.23,          // BM25 relevance score
      fields: { ... },      // stored fields (if returnStored: true)
      highlights: { ... },  // highlighted snippets (if requested)
    },
  ],
  nextCursor: '...',        // for pagination (if more results exist)
  aggregations: { ... },    // aggregation results (if requested)
}

index.compact()

Merges index segments for better read performance. Call this periodically after many commits.

index.close()

Closes the index and releases native resources. Any subsequent method calls will throw.

new RemoteIndex(baseUrl, indexName, options?)

Connects to a remote searchlite-http server.

const { RemoteIndex } = require('searchlite-js');

// Basic connection
const index = new RemoteIndex('http://localhost:8080', 'products');

// With write key for protected indexes
const index = new RemoteIndex('http://localhost:8080', 'products', {
  writeKey: 'my-secret-key',
});

// With custom fetch (for testing or custom transports)
const index = new RemoteIndex('http://localhost:8080', 'products', {
  fetch: myCustomFetch,
});

RemoteIndex implements the same SearchIndex interface as EmbeddedIndex — all methods (add, addMany, commit, compact, search, close) work identically. The close() method is a no-op since HTTP connections are stateless.

Internally, methods map to searchlite-http endpoints:

| Method | HTTP Endpoint | |--------|--------------| | add(doc) | POST /indexes/:name/bulk | | addMany(docs) | POST /indexes/:name/bulk | | commit() | POST /indexes/:name/commit | | compact() | POST /indexes/:name/compact | | search(query) | POST /indexes/:name/search |

Schema

Schemas define the fields in your documents. You can use either the shorthand format (Node.js only) or the full JSON Schema format with searchlite: vocabulary keywords.

Shorthand Format (Node.js only)

The shorthand maps field names to type strings. expandSchema() converts this to JSON Schema internally.

| Shorthand | JSON Schema Output | Description | |-----------|-------------------|-------------| | 'text' | { type: "string" } | Full-text searchable with BM25 scoring | | 'keyword' | { type: "string", "searchlite:kind": "keyword" } | Exact-match filtering and aggregations | | 'integer' | { type: "integer" } | 64-bit integer, range filters | | 'float' | { type: "number" } | 64-bit float, range filters |

JSON Schema Format

The canonical format uses standard JSON Schema types with searchlite: vocabulary keywords for search-engine-specific behavior. This format is shared across all searchlite clients.

{
  type: 'object',
  properties: {
    title: { type: 'string' },                                          // text (default)
    body: { type: 'string', 'searchlite:stored': false },               // text, not stored
    status: { type: 'string', 'searchlite:kind': 'keyword' },           // keyword
    count: { type: 'integer', 'searchlite:stored': true },              // integer, stored
    price: { type: 'number' },                                          // float
    notes: { type: ['string', 'null'] },                                // nullable text
  },
}

searchlite: Vocabulary Keywords

These keywords extend standard JSON Schema to control search-engine behavior.

For text fields ({ type: "string" } without searchlite:kind):

| Keyword | Type | Default | Description | |---------|------|---------|-------------| | searchlite:stored | boolean | true | Include in stored fields for retrieval | | searchlite:indexed | boolean | true | Include in full-text index | | searchlite:analyzer | string | "default" | Text analysis pipeline |

For keyword fields ({ type: "string", "searchlite:kind": "keyword" }):

| Keyword | Type | Default | Description | |---------|------|---------|-------------| | searchlite:stored | boolean | true | Include in stored fields | | searchlite:indexed | boolean | true | Include in term index | | searchlite:fast | boolean | true | Enable fast-field for filtering and aggregations |

For numeric fields ({ type: "integer" } or { type: "number" }):

| Keyword | Type | Default | Description | |---------|------|---------|-------------| | searchlite:stored | boolean | false | Include in stored fields | | searchlite:fast | boolean | true | Enable fast-field for range filters |

Nullable fields: Use a JSON Schema type array to allow null values, e.g. { type: ["string", "null"] } or { type: ["integer", "null"] }.

Index-level keywords (on the root schema object):

| Keyword | Type | Default | Description | |---------|------|---------|-------------| | searchlite:docIdField | string | "_id" | Name of the document ID field | | searchlite:analyzers | array | -- | Custom analyzer definitions |

Search Options

The search() method accepts a request object with these fields:

index.search({
  // Required
  query: 'search terms',             // string or structured query object

  // Pagination
  limit: 10,                         // max results to return (default: 10, max: 10000)
  from: 0,                           // offset for pagination
  cursor: '...',                     // cursor from previous result's nextCursor
  searchAfter: [...],                // keyset pagination values

  // Field control
  returnStored: false,               // include stored fields in hits
  returnHits: true,                  // include hits array in response

  // Scoring
  execution: 'wand',                 // scoring algorithm: 'wand', 'bmw', or 'bm25'
  trackTotalHits: true,              // count all matches (slower but accurate)
  explain: false,                    // include score explanations in hits
  profile: false,                    // include query execution profile

  // Filtering & sorting
  filter: { ... },                   // pre-scoring filter (see Filters)
  sort: [{ price: 'asc' }],         // sort by field values

  // Features
  fuzzy: { maxEdits: 1 },            // fuzzy matching
  highlightField: 'body',            // field to highlight
  aggs: { ... },                     // aggregations (see Aggregations)
  collapse: { field: 'brand' },      // deduplicate by field
});

Filters

Filters narrow results without affecting relevance scores. They use PascalCase variant names.

Keyword Filters

// Exact match
{ KeywordEq: { field: 'status', value: 'active' } }

// Match any value in a set
{ KeywordIn: { field: 'color', values: ['red', 'blue', 'green'] } }

Range Filters

// Integer range
{ I64Range: { field: 'year', min: 2020, max: 2024 } }

// Float range
{ F64Range: { field: 'price', min: 10.0, max: 99.99 } }

Boolean Combinations

// AND — all conditions must match
{ And: [
  { KeywordEq: { field: 'brand', value: 'AudioCo' } },
  { I64Range: { field: 'year', min: 2023, max: 2025 } },
]}

// OR — any condition matches
{ Or: [
  { KeywordEq: { field: 'brand', value: 'AudioCo' } },
  { KeywordEq: { field: 'brand', value: 'SoundPro' } },
]}

// NOT — exclude matches
{ Not: { KeywordEq: { field: 'status', value: 'discontinued' } } }

Nested Filters

For nested document fields:

{ Nested: {
  path: 'variants',
  filter: { KeywordEq: { field: 'variants.color', value: 'red' } },
}}

Aggregations

Compute facets and statistics alongside search results. Aggregation types use snake_case type fields.

Terms Aggregation

Count documents by keyword field values:

const results = index.search({
  query: 'headphones',
  aggs: {
    brands: {
      type: 'terms',
      field: 'brand',
      size: 10,
    },
  },
});

console.log(results.aggregations.brands);
// { buckets: [{ key: 'AudioCo', doc_count: 5 }, { key: 'SoundPro', doc_count: 3 }] }

Stats Aggregation

Get min, max, average, sum, and count for a numeric field:

const results = index.search({
  query: 'headphones',
  aggs: {
    priceStats: {
      type: 'stats',
      field: 'price',
    },
  },
});

Range Aggregation

Bucket documents into numeric ranges:

const results = index.search({
  query: '*',
  aggs: {
    priceRanges: {
      type: 'range',
      field: 'price',
      keyed: false,
      ranges: [
        { key: 'cheap', to: 50 },
        { key: 'mid', from: 50, to: 100 },
        { key: 'premium', from: 100 },
      ],
    },
  },
});

Histogram Aggregation

Fixed-width numeric buckets:

const results = index.search({
  query: '*',
  aggs: {
    priceHist: {
      type: 'histogram',
      field: 'price',
      interval: 25,
    },
  },
});

Nested Aggregations

Aggregations can be nested to create multi-level facets:

const results = index.search({
  query: '*',
  aggs: {
    byBrand: {
      type: 'terms',
      field: 'brand',
      aggs: {
        avgPrice: {
          type: 'stats',
          field: 'price',
        },
      },
    },
  },
});

Structured Queries

For advanced search, pass a structured query object instead of a string. Query types use a snake_case type field.

Multi-Match

Search across multiple fields with boosting:

index.search({
  query: {
    type: 'multi_match',
    query: 'wireless noise cancelling',
    fields: [
      { field: 'name', boost: 2.0 },
      { field: 'description' },
    ],
    fuzziness: 'AUTO',
  },
});

Boolean Queries

Combine multiple query clauses:

index.search({
  query: {
    type: 'bool',
    must: [
      { type: 'query_string', query: 'headphones' },
    ],
    should: [
      { type: 'term', field: 'brand', value: 'AudioCo' },
    ],
    must_not: [
      { type: 'term', field: 'status', value: 'discontinued' },
    ],
  },
});

Phrase Matching

Match exact phrases with optional slop (word distance):

index.search({
  query: {
    type: 'phrase',
    field: 'description',
    terms: ['noise', 'cancelling'],
    slop: 1,
  },
});

Prefix, Wildcard, and Regex

// Prefix
index.search({ query: { type: 'prefix', field: 'name', value: 'wire' } });

// Wildcard (? = single char, * = any chars)
index.search({ query: { type: 'wildcard', field: 'name', value: 'head*' } });

// Regex
index.search({ query: { type: 'regex', field: 'name', value: 'head(phone|set)s?' } });

Sorting

Sort results by field values instead of relevance:

// Simple ascending
index.search({ query: 'headphones', sort: [{ price: 'asc' }] });

// Multiple sort fields
index.search({
  query: 'headphones',
  sort: [
    { year: 'desc' },
    { price: 'asc' },
  ],
});

Pagination

Cursor-Based (recommended)

Use cursors for efficient deep pagination:

// First page
const page1 = index.search({ query: 'headphones', limit: 10 });

// Next page
const page2 = index.search({
  query: 'headphones',
  limit: 10,
  cursor: page1.nextCursor,
});

Offset-Based

Use from for simple offset pagination (less efficient for deep pages):

const page3 = index.search({ query: 'headphones', limit: 10, from: 20 });

Fuzzy Search

Allow typos and misspellings:

index.search({
  query: 'headphoens',  // typo
  fuzzy: {
    maxEdits: 2,         // allow up to 2 character edits
    prefixLength: 2,     // first 2 chars must match exactly
  },
});

Highlighting

There are two highlighting modes:

Simple — use highlightField for a quick snippet from a single field:

const results = index.search({
  query: 'wireless',
  highlightField: 'description',
});

console.log(results.hits[0].snippet);
// "... <em>Wireless</em> noise-cancelling headphones ..."

Multi-field — use highlight for full control over multiple fields with custom tags:

const results = index.search({
  query: 'wireless',
  highlight: {
    fields: {
      name: { pre_tag: '<b>', post_tag: '</b>', fragment_size: 64, number_of_fragments: 1 },
      description: { pre_tag: '<em>', post_tag: '</em>', fragment_size: 160, number_of_fragments: 2 },
    },
  },
});

console.log(results.hits[0].highlights);
// { name: ['<b>Wireless</b> Headphones'], description: ['<em>Wireless</em> noise-cancelling...'] }

Result Collapsing

Deduplicate results by a keyword field (e.g., show one result per brand):

const results = index.search({
  query: 'headphones',
  collapse: { field: 'brand' },
});

Write Key Protection

Protect an index with a write key to prevent unauthorized modifications:

// Create with write key
const index = new EmbeddedIndex('./protected', {
  schema: { title: 'text' },
  writeKey: 'my-secret',
});

// Reopen — must provide the same write key to write
const index = new EmbeddedIndex('./protected', { writeKey: 'my-secret' });

Error Handling

Constructor errors throw synchronously. All other methods return rejected Promises on failure:

// Constructor errors throw synchronously
try {
  new EmbeddedIndex('./nonexistent');
} catch (e) {
  // "index does not exist; provide a schema to create it"
}

// Async method errors — use try/await
const index = new EmbeddedIndex('./my-index', { schema: { body: 'text' } });

try {
  await index.add('not an object');
} catch (e) {
  // validation error — documents must be plain objects
}

// Operations on closed index
await index.close();
try {
  await index.search('hello');
} catch (e) {
  // "index is closed"
}

// RemoteIndex HTTP errors
const remote = new RemoteIndex('http://localhost:8080', 'missing');
try {
  await remote.search('hello');
} catch (e) {
  // "searchlite search failed (404): index 'missing' does not exist"
}

Complete Example

const { EmbeddedIndex } = require('searchlite-js');

// Create an index for a recipe database
const index = new EmbeddedIndex('./recipes', {
  schema: {
    title: 'text',
    ingredients: 'text',
    cuisine: 'keyword',
    prepTime: 'integer',
    rating: 'float',
  },
});

// Index some recipes
index.addMany([
  {
    _id: 'pad-thai',
    title: 'Classic Pad Thai',
    ingredients: 'rice noodles, shrimp, peanuts, bean sprouts, lime',
    cuisine: 'thai',
    prepTime: 30,
    rating: 4.8,
  },
  {
    _id: 'carbonara',
    title: 'Spaghetti Carbonara',
    ingredients: 'spaghetti, eggs, pecorino, guanciale, black pepper',
    cuisine: 'italian',
    prepTime: 25,
    rating: 4.6,
  },
  {
    _id: 'tacos',
    title: 'Fish Tacos',
    ingredients: 'cod, tortillas, cabbage, lime, chipotle mayo',
    cuisine: 'mexican',
    prepTime: 20,
    rating: 4.5,
  },
]);
index.commit();

// Simple text search
const results = index.search('noodles');
console.log(`Found ${results.totalHits} recipes`);

// Search with filter and aggregation
const filtered = index.search({
  query: 'lime',
  filter: { I64Range: { field: 'prepTime', min: 0, max: 30 } },
  returnStored: true,
  aggs: {
    byCuisine: { type: 'terms', field: 'cuisine' },
  },
});

for (const hit of filtered.hits) {
  console.log(`${hit.docId}: score ${hit.score.toFixed(2)}`);
}
console.log('Cuisines:', filtered.aggregations.byCuisine);

index.close();

Typed Search with Zod

Pass a Zod schema as the first argument to search() and every hit's fields property is validated and fully typed at compile time. No more as any casts or manual null checks on search results.

When you pass a schema, returnStored is set automatically — you don't need to specify it.

Basic typed search

import { EmbeddedIndex } from 'searchlite-js';
import { z } from 'zod';

const index = new EmbeddedIndex('./products', {
  schema: { name: 'text', brand: 'keyword', price: 'float' },
});

// ... add documents and commit ...

// Define the shape you expect back
const ProductFields = z.object({
  name: z.string(),
  brand: z.string(),
  price: z.number(),
});

// Pass the schema as the first argument — results are typed
const results = await index.search(ProductFields, 'wireless headphones');

for (const hit of results.hits) {
  // hit.fields is { name: string; brand: string; price: number } — fully typed
  console.log(`${hit.fields.name} by ${hit.fields.brand} — $${hit.fields.price}`);
}

Typed search with filters and aggregations

The schema works with structured queries too:

const BrandPrice = z.object({
  brand: z.string(),
  price: z.number(),
});

const results = await index.search(BrandPrice, {
  query: 'headphones',
  filter: { F64Range: { field: 'price', min: 20, max: 100 } },
  sort: [{ price: 'asc' }],
  aggs: {
    brands: { type: 'terms', field: 'brand', size: 5 },
  },
});

// Fields are typed, aggregations are available alongside
for (const hit of results.hits) {
  console.log(`${hit.fields.brand}: $${hit.fields.price.toFixed(2)}`);
}
console.log('Top brands:', results.aggregations?.brands);

Transforms and defaults

Zod transforms and defaults run during validation, so you can reshape data as it comes out of the index:

const Product = z.object({
  name: z.string().transform((s) => s.toUpperCase()),
  price: z.number(),
  currency: z.string().default('USD'),
});

const results = await index.search(Product, 'headphones');

// Transforms are applied — name is uppercased, currency defaults to "USD"
console.log(results.hits[0].fields.name);     // "WIRELESS HEADPHONES"
console.log(results.hits[0].fields.currency);  // "USD"

Optional and partial fields

Use .optional() for fields that may not be stored on every document:

const FlexProduct = z.object({
  name: z.string(),
  brand: z.string().optional(),
  price: z.number().optional(),
});

const results = await index.search(FlexProduct, 'headphones');
// hit.fields.brand is string | undefined — TypeScript knows

Validation errors

If a document's stored fields don't match your schema, search() throws with a clear error that includes the document ID and field path:

const StrictFields = z.object({
  name: z.string(),
  price: z.number(), // will fail if price is missing or wrong type
});

try {
  await index.search(StrictFields, 'headphones');
} catch (e) {
  // Error: 'Invalid fields on hit 0 (docId: "product-1"):\n Required at "price"'
}

End-to-end example: typed product search

import { EmbeddedIndex } from 'searchlite-js';
import { z } from 'zod';

// Schema for the index
const index = new EmbeddedIndex('./shop', {
  schema: {
    name: 'text',
    description: 'text',
    category: 'keyword',
    price: 'float',
    inStock: 'integer',
  },
});

// Index a product catalog
await index.addMany([
  { _id: 'sku-1', name: 'Wireless Earbuds', description: 'Bluetooth 5.3 with ANC', category: 'audio', price: 59.99, inStock: 142 },
  { _id: 'sku-2', name: 'Studio Headphones', description: 'Over-ear open-back for mixing', category: 'audio', price: 199.99, inStock: 38 },
  { _id: 'sku-3', name: 'USB Microphone', description: 'Condenser mic for podcasting', category: 'microphones', price: 89.99, inStock: 67 },
  { _id: 'sku-4', name: 'Webcam 4K', description: 'Ultra HD with autofocus', category: 'video', price: 129.99, inStock: 0 },
]);
await index.commit();

// Zod schema for validated results
const CatalogItem = z.object({
  name: z.string(),
  category: z.string(),
  price: z.number(),
  inStock: z.number().transform((n) => n > 0),  // transform count to boolean
});

type CatalogItem = z.infer<typeof CatalogItem>;
// { name: string; category: string; price: number; inStock: boolean }

// Typed search with filter and facets
const results = await index.search(CatalogItem, {
  query: { type: 'multi_match', query: 'audio', fields: [{ field: 'name' }, { field: 'description', boost: 0.5 }] },
  filter: { F64Range: { field: 'price', min: 0, max: 150 } },
  aggs: {
    categories: { type: 'terms', field: 'category' },
    priceStats: { type: 'stats', field: 'price' },
  },
  highlightField: 'description',
});

for (const hit of results.hits) {
  const { name, category, price, inStock } = hit.fields;
  const badge = inStock ? 'In Stock' : 'Out of Stock';
  console.log(`[${badge}] ${name} (${category}) — $${price}`);
  if (hit.snippet) console.log(`  ${hit.snippet}`);
}

await index.close();

TypeScript

Full type definitions are included. Import and use with full IntelliSense:

import { EmbeddedIndex, RemoteIndex, SchemaDefinition } from 'searchlite-js';
import type { SearchIndex, SearchResult, TypedSearchResult } from 'searchlite-js';
import { z } from 'zod';

// Both index types implement the same SearchIndex interface
const schema: SchemaDefinition = { title: 'text', tag: 'keyword' };
const embedded = new EmbeddedIndex('./my-index', { schema });
await embedded.add({ _id: '1', title: 'Hello', tag: 'greeting' });
await embedded.commit();

// Untyped search — returns SearchResult with optional fields
const basic: SearchResult = await embedded.search({ query: 'hello', returnStored: true });

// Typed search — returns TypedSearchResult<T> with validated fields
const Fields = z.object({ title: z.string(), tag: z.string() });
const typed: TypedSearchResult<z.infer<typeof Fields>> = await embedded.search(Fields, 'hello');
// typed.hits[0].fields.title — string, guaranteed

// RemoteIndex works the same way
const remote = new RemoteIndex('http://localhost:8080', 'my-index');
const remoteTyped = await remote.search(Fields, 'hello');

License

MIT