DocsGuidesSearch & live queries

Search & live queries

Search and live queries: subscribe to filtered subsets of a map with useQuery; the subscription updates automatically when matching records change. The subscription works offline — it runs against local data when the server is unreachable.

For text-heavy data there are two distinct tools, and they are not the same thing:

  • Client-side InvertedIndex — in-memory token matching (contains / AND / OR) over an IndexedLWWMap. Works fully offline, no server round-trip, but returns unranked matches (no relevance scoring).
  • Server-side client.search() — true BM25 relevance ranking via the server’s tantivy index. Returns scored, sorted results, but requires a reachable server.

Pick the client-side index for offline filtering of a known set; pick client.search() when you need relevance-ranked results across a server-held collection.

Live subscriptions

Changes push to subscribers immediately — no polling required.

Predicate filters

Simple equality, range, regex, and logical operators.

Full-text search

Client-side InvertedIndex for offline token matching; server-side client.search() for BM25 relevance ranking.


Reactive queries (React)

Use useQuery with a where clause or predicate to subscribe to a filtered view of a map. The subscription updates automatically whenever matching records change — locally or via sync.

components/ActiveTodos.tsx
import { useQuery, useMutation } from '@topgunbuild/react';

interface Todo {
  text: string;
  completed: boolean;
  createdAt: number;
}

export function ActiveTodos() {
  // Subscribe to incomplete todos, sorted newest first
  const { data: todos, loading, error } = useQuery<Todo>('todos', {
    where: { completed: false },
    sort: { createdAt: 'desc' },
    limit: 50,
  });

  const { update } = useMutation<Todo>('todos');

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo._key}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => update(todo._key, { completed: true })}
          />
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

Reactive queries (imperative)

Outside React, use client.query(mapName, filter) to get a QueryHandle. Subscribe to the handle and call unsubscribe() when done.

See Client API reference for the full QueryFilter and QueryHandle surfaces.

src/workers/order-watcher.ts
import { TopGunClient } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';

const client = new TopGunClient({
  serverUrl: 'ws://localhost:8080',
  storage: new IDBAdapter(),
});

await client.start();

const handle = client.query('orders', {
  where: { status: 'pending' },
  sort: { createdAt: 'desc' },
  limit: 20,
});

const unsubscribe = handle.subscribe((results) => {
  console.log('Pending orders:', results.length);
  // results is QueryResultItem<T>[] — each item carries _key
});

// Stop receiving updates when done
unsubscribe();

Predicates

For complex filtering — range comparisons, logical operators, regex — use the Predicates builder from @topgunbuild/client. The result is passed to the predicate field of the query filter.

components/AffordableElectronics.tsx
import { Predicates } from '@topgunbuild/client';
import { useQuery } from '@topgunbuild/react';

interface Product {
  name: string;
  price: number;
  category: string;
  inStock: boolean;
}

export function AffordableElectronics() {
  const { data: products } = useQuery<Product>('products', {
    predicate: Predicates.and(
      Predicates.greaterThan('price', 10),
      Predicates.lessThanOrEqual('price', 200),
      Predicates.equal('category', 'electronics'),
      Predicates.equal('inStock', true),
    ),
    sort: { price: 'asc' },
  });

  return (
    <ul>
      {products.map(p => (
        <li key={p._key}>{p.name} — ${p.price}</li>
      ))}
    </ul>
  );
}

Available predicate methods

MethodDescriptionExample
equal(attr, value)Exact matchPredicates.equal('status', 'active')
notEqual(attr, value)Not equalPredicates.notEqual('type', 'draft')
greaterThan(attr, value)Greater thanPredicates.greaterThan('price', 100)
greaterThanOrEqual(attr, value)Greater or equalPredicates.greaterThanOrEqual('stock', 0)
lessThan(attr, value)Less thanPredicates.lessThan('age', 18)
lessThanOrEqual(attr, value)Less or equalPredicates.lessThanOrEqual('priority', 5)
like(attr, pattern)SQL-like pattern (% = any, _ = single char)Predicates.like('name', '%john%')
regex(attr, pattern)Regular expressionPredicates.regex('email', '^.*@gmail\\.com$')
between(attr, from, to)Range (inclusive)Predicates.between('price', 10, 100)
isIn(attr, values)Match any value in listPredicates.isIn('status', ['active', 'pending'])
isNull(attr)Field is null or missingPredicates.isNull('deletedAt')
isNotNull(attr)Field exists and is not nullPredicates.isNotNull('email')
and(...predicates)Logical ANDPredicates.and(p1, p2, p3)
or(...predicates)Logical ORPredicates.or(p1, p2)
not(predicate)Logical NOTPredicates.not(p1)

isIn not in

The list-membership predicate is Predicates.isIn() (not Predicates.in()) because `in` is a reserved JavaScript keyword.


Client-side token search with InvertedIndex

For offline text filtering, add an InvertedIndex to an IndexedLWWMap. The index maps tokens to document keys, enabling O(K) search (where K is the number of matching tokens) instead of a full scan. This runs entirely in-memory on the client, so it works offline.

Token matching, not BM25

queryValues({ type: 'contains' }) returns unranked matches — every document that contains the tokens, in no particular relevance order. There is no scoring here. For BM25 relevance ranking, use the server-side client.search() shown below.

src/lib/search.ts
import {
  IndexedLWWMap,
  simpleAttribute,
  HLC,
} from '@topgunbuild/core';

interface Article {
  title: string;
  body: string;
  author: string;
}

const hlc = new HLC('node-1');
const articles = new IndexedLWWMap<string, Article>(hlc);

// Add an inverted index on the 'title' field
const titleAttr = simpleAttribute<Article, string>('title', a => a.title);
articles.addInvertedIndex(titleAttr);

// Index a document
articles.set('a1', {
  title: 'Introduction to Machine Learning',
  body: 'Machine learning is a subset of artificial intelligence.',
  author: 'Alice',
});

articles.set('a2', {
  title: 'Deep Learning Tutorial',
  body: 'Deep learning uses many-layer neural networks.',
  author: 'Bob',
});

// Token search — O(K) lookup
const results = articles.queryValues({
  type: 'contains',
  attribute: 'title',
  value: 'learning',
});
// Returns both articles ('learning' appears in both titles)

// AND semantics: all tokens must match
const narrowed = articles.queryValues({
  type: 'contains',
  attribute: 'title',
  value: 'machine learning',  // "machine" AND "learning"
});
// Returns only a1

Query types

Query typeSemanticsUse case
containsAll tokens must match (AND)Search box with multiple words
containsAllAll specified values presentFilter by required tags
containsAnyAny token matches (OR)Search with alternatives

Server-side BM25 search with client.search()

When you need relevance-ranked results — not just “does it contain these tokens” — use client.search(). This runs against the server’s tantivy full-text index and returns results scored and sorted by BM25. The server indexes every map for full-text search by default, so there is no per-map flag to switch on first.

Unlike the client-side InvertedIndex, client.search() requires a reachable server — it is not an offline path.

src/lib/bm25-search.ts
import { TopGunClient } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';

const client = new TopGunClient({
  serverUrl: 'ws://localhost:8080',
  storage: new IDBAdapter(),
});

await client.start();

// BM25-ranked search, scored and sorted server-side
const results = await client.search<Article>('articles', 'machine learning', {
  limit: 20,
  minScore: 0.5,
  boost: { title: 2.0, body: 1.0 }, // weight title matches higher than body
});

for (const r of results) {
  // each result carries the BM25 relevance score and the terms that matched
  console.log(`${r.key}: ${r.value.title} (score: ${r.score})`, r.matchedTerms);
}

For a live, auto-updating ranked result set, use client.searchSubscribe() with the same options — see the Client API reference.

When to use which

Client-side InvertedIndexServer-side client.search()
RankingUnranked token matchBM25 relevance score
Offline✅ in-memory, no server❌ requires server
ScopeRecords loaded on the clientThe full server-held map
ReturnsMatching values{ key, value, score, matchedTerms }

Use predicate queries for structured filtering (equality, range) and InvertedIndex for text search. Combine them by chaining queryValues on an IndexedLWWMap or by applying predicate queries to the results of a text search.

components/ProductSearch.tsx
import {
  IndexedLWWMap,
  simpleAttribute,
  HLC,
} from '@topgunbuild/core';
import { Predicates } from '@topgunbuild/client';
import { useQuery } from '@topgunbuild/react';

interface Product {
  name: string;
  category: string;
  price: number;
  inStock: boolean;
}

// Use IndexedLWWMap for text search + getMap for reactive queries
// Approach: predicate query first (from useQuery), then apply text filter client-side
// OR: use IndexedLWWMap directly for in-memory search without server round-trip

const hlc = new HLC('node-search');
const productIndex = new IndexedLWWMap<string, Product>(hlc);

const nameAttr = simpleAttribute<Product, string>('name', p => p.name);
productIndex.addInvertedIndex(nameAttr);

function searchProducts(query: string, maxPrice: number) {
  // Step 1: text search — returns products whose name contains the query tokens
  const textMatches = productIndex.queryValues({
    type: 'contains',
    attribute: 'name',
    value: query,
  });

  // Step 2: filter by price client-side (or use a predicate query on the server map)
  return textMatches.filter(p => p.price <= maxPrice && p.inStock);
}

// In React: use useQuery for the reactive layer + run text search on the result set
export function ProductSearch() {
  const [searchTerm, setSearchTerm] = React.useState('');

  const { data: products } = useQuery<Product>('products', {
    predicate: Predicates.and(
      Predicates.equal('inStock', true),
      Predicates.lessThanOrEqual('price', 500),
    ),
  });

  const filtered = React.useMemo(() => {
    if (!searchTerm) return products;
    const lower = searchTerm.toLowerCase();
    return products.filter(p => p.name.toLowerCase().includes(lower));
  }, [products, searchTerm]);

  return (
    <div>
      <input value={searchTerm} onChange={e => setSearchTerm(e.target.value)} placeholder="Search..." />
      <ul>{filtered.map(p => <li key={p._key}>{p.name} — ${p.price}</li>)}</ul>
    </div>
  );
}

Next steps