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 anIndexedLWWMap. 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.
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.
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.
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
| Method | Description | Example |
|---|---|---|
equal(attr, value) | Exact match | Predicates.equal('status', 'active') |
notEqual(attr, value) | Not equal | Predicates.notEqual('type', 'draft') |
greaterThan(attr, value) | Greater than | Predicates.greaterThan('price', 100) |
greaterThanOrEqual(attr, value) | Greater or equal | Predicates.greaterThanOrEqual('stock', 0) |
lessThan(attr, value) | Less than | Predicates.lessThan('age', 18) |
lessThanOrEqual(attr, value) | Less or equal | Predicates.lessThanOrEqual('priority', 5) |
like(attr, pattern) | SQL-like pattern (% = any, _ = single char) | Predicates.like('name', '%john%') |
regex(attr, pattern) | Regular expression | Predicates.regex('email', '^.*@gmail\\.com$') |
between(attr, from, to) | Range (inclusive) | Predicates.between('price', 10, 100) |
isIn(attr, values) | Match any value in list | Predicates.isIn('status', ['active', 'pending']) |
isNull(attr) | Field is null or missing | Predicates.isNull('deletedAt') |
isNotNull(attr) | Field exists and is not null | Predicates.isNotNull('email') |
and(...predicates) | Logical AND | Predicates.and(p1, p2, p3) |
or(...predicates) | Logical OR | Predicates.or(p1, p2) |
not(predicate) | Logical NOT | Predicates.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.
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 type | Semantics | Use case |
|---|---|---|
contains | All tokens must match (AND) | Search box with multiple words |
containsAll | All specified values present | Filter by required tags |
containsAny | Any 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.
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 InvertedIndex | Server-side client.search() | |
|---|---|---|
| Ranking | Unranked token match | BM25 relevance score |
| Offline | ✅ in-memory, no server | ❌ requires server |
| Scope | Records loaded on the client | The full server-held map |
| Returns | Matching values | { key, value, score, matchedTerms } |
Combining queries and search
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.
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
- Schema-typed data — add compile-time types to query results
- Real-time collaboration — apply live queries to shared maps
- Client API reference — full
query()andQueryHandlesurface