Client API

Hook-first API for React UI code

For React UI code, prefer the hook-first API: useQuery<Todo>('todos') for reads, useMutation<Todo>('todos') for writes (see /docs/quickstart for the canonical pattern). This reference page documents the lower-level TopGunClient<TSchema> API surface used inside hooks and in non-React contexts (Node.js, service workers, testing).

TopGunClient<TSchema> is the imperative SDK entry point. It manages local CRDT maps, the sync engine, and the connection lifecycle. The class is generic over an optional schema type that narrows getMap, getORMap, and query to your application types.

interface AppSchema {
  todos: { text: string; done: boolean };
  users: { name: string; email: string };
}

const client = new TopGunClient<AppSchema>({
  storage: new IDBAdapter(),
});

// Narrowed: LWWMap<string, { text: string; done: boolean }>
const todos = client.getMap('todos');

The untyped fallback overloads remain for callers that pass explicit type parameters.

Constructor

new TopGunClient<TSchema>(config: TopGunClientConfig)

TopGunClientConfig accepts:

FieldTypeDescription
storageIStorageAdapterRequired. Local persistence (typically new IDBAdapter()).
serverUrlstringOptional. WebSocket URL for single-server mode. Mutually exclusive with cluster.
clusterTopGunClusterConfigOptional. Cluster configuration. Mutually exclusive with serverUrl.
nodeIdstringOptional. Auto-generated UUID if omitted.
backoffPartial<BackoffConfig>Optional. Reconnect backoff tuning.
backpressurePartial<BackpressureConfig>Optional. Backpressure thresholds and strategy.
authAuthProviderOptional. Pluggable auth provider (Clerk, Firebase, BetterAuth, Custom).

The constructor branches on three mutually exclusive modes:

Local-only mode (default)

When neither serverUrl nor cluster is supplied, the client uses NullConnectionProvider. Operations stay in memory and IndexedDB; no socket is opened. This is the default for offline demos, tests, and prototype apps.

import { TopGunClient } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';

const client = new TopGunClient({
  storage: new IDBAdapter(),
});

Single-server mode

Supply serverUrl to connect to one TopGun server over WebSocket.

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

Cluster mode

Supply cluster.seeds (one or more seed URLs) for partition-aware routing across multiple nodes.

const client = new TopGunClient({
  cluster: {
    seeds: [
      'ws://node1.example.com:8080',
      'ws://node2.example.com:8080',
      'ws://node3.example.com:8080',
    ],
    smartRouting: true,
    connectionsPerNode: 2,
    connectionTimeoutMs: 5000,
  },
  storage: new IDBAdapter(),
});

TopGunClusterConfig fields: seeds (required), connectionsPerNode (default 1), smartRouting (default true), partitionMapRefreshMs (default 30000), connectionTimeoutMs (default 5000), retryAttempts (default 3).

Supplying both serverUrl and cluster throws at construction.

Advanced: HTTP-sync and auto-connection providers

For environments without WebSockets (Cloudflare Workers, serverless, restrictive proxies), build a custom SyncEngine with HttpSyncProvider or AutoConnectionProvider. These are advanced and bypass the TopGunClient wrapper.

import { HttpSyncProvider, SyncEngine } from '@topgunbuild/client';
import { HLC } from '@topgunbuild/core';
import { IDBAdapter } from '@topgunbuild/adapters';

const hlc = new HLC('client-1');
const provider = new HttpSyncProvider({
  url: 'https://your-api.example.com',
  clientId: 'client-1',
  hlc,
  authToken: 'your-jwt-token',
  syncMaps: ['todos'],
  pollIntervalMs: 5000,
});

const engine = new SyncEngine({
  nodeId: 'client-1',
  connectionProvider: provider,
  storageAdapter: new IDBAdapter(),
});

AutoConnectionProvider attempts WebSocket first, then falls back to HTTP polling. Same setup pattern.

Core Methods

start()

public async start(): Promise<void>

Initializes the storage adapter (calls storage.initialize('topgun_offline_db')). For local-only mode this is optional — IDBAdapter initializes lazily on first read/write. Calling start() eagerly is useful when you want to surface storage errors before the UI renders.

getMap(name)

getMap<K extends keyof TSchema & string>(name: K): LWWMap<string, TSchema[K]>;
getMap<K = string, V = any>(name: string): LWWMap<K, V>;

Returns an LWWMap (last-write-wins CRDT) for the given name. Creates the map on first call and restores its state from storage asynchronously.

const users = client.getMap<string, User>('users');
users.set('u1', { name: 'Alice' });
const user = users.get('u1');

getORMap(name)

getORMap<K extends keyof TSchema & string>(name: K): ORMap<string, TSchema[K]>;
getORMap<K = string, V = any>(name: string): ORMap<K, V>;

Returns an ORMap (observed-remove CRDT) for multi-value-per-key data.

const tags = client.getORMap<string, string>('tags');
tags.add('post:123', 'javascript');
tags.add('post:123', 'typescript');
const postTags = tags.get('post:123'); // ['javascript', 'typescript']

query(mapName, filter)

query<K extends keyof TSchema & string>(mapName: K, filter: QueryFilter): QueryHandle<TSchema[K]>;
query<T = any>(mapName: string, filter: QueryFilter): QueryHandle<T>;

Returns a live query subscription. The handle emits an initial result and delta updates. See Search & live queries for the canonical use-case-driven example.

const handle = client.query<Todo>('todos', {
  where: { completed: false },
  sort: { createdAt: 'desc' },
  limit: 10,
});

const unsubscribe = handle.subscribe((results) => {
  console.log('Results:', results);
});

// Cursor pagination
const { nextCursor, hasMore } = handle.getPaginationInfo();
if (hasMore && nextCursor) {
  const nextPage = client.query<Todo>('todos', {
    where: { completed: false },
    sort: { createdAt: 'desc' },
    limit: 10,
    cursor: nextCursor,
  });
}

QueryHandle<T> methods: subscribe(cb), onDelta(listener), consumeChanges(), getLastChange(), getPendingChanges(), clearChanges(), resetChangeTracker(), getFilter(), getMapName(), getPaginationInfo(), onPaginationChange(listener), updatePaginationInfo(info), syncState (getter), onSyncStateChange(listener).

Knowing when a result is authoritative — subscribe { settled }

A live query fires immediately with whatever is in the local cache, then fires again once the server answers. Most UIs don’t care about the difference — but if you need to tell “the server confirmed this” apart from “this is just my optimistic local view” (for example, to hide a spinner only once data is real), subscribe passes an optional second argument:

const unsubscribe = handle.subscribe((results, meta) => {
  // meta.settled is false on the first local/optimistic frame,
  // and true once an authoritative server response has arrived
  // (including an empty result set — settled true with zero rows
  // means the server genuinely has no matching records).
  if (meta?.settled) {
    hideSpinner();
  }
  render(results);
});

SubscribeMeta is { settled: boolean }. The second argument is optional and additive — existing (results) => void callbacks keep working unchanged. Exported types: SubscribeCallback, SubscribeMeta.

queryOnce(mapName, filter, opts?)

Fetch a result set once and get back authoritative server data — no live subscription, no stale local guesses. Use this when you need a definitive answer at a single point in time (an export, a server-validated check, an AI/agent read) rather than a feed that keeps updating.

queryOnce<K extends keyof TSchema & string>(
  mapName: K,
  filter: QueryFilter,
  opts?: QueryOnceOptions,
): Promise<QueryResultItem<TSchema[K]>[]>;
queryOnce<T = any>(
  mapName: string,
  filter: QueryFilter,
  opts?: QueryOnceOptions,
): Promise<QueryResultItem<T>[]>;
interface QueryOnceOptions {
  timeoutMs?: number; // default: DEFAULT_QUERY_ONCE_TIMEOUT_MS (5000)
  allowLocal?: boolean; // default: false
}

A normal resolve always returns settled, authoritative server data. An empty array means the server genuinely has no matching rows — never “we couldn’t reach the server”. queryOnce never silently hands you stale local data.

const todos = await client.queryOnce<Todo>('todos', {
  where: { completed: false },
  sort: { createdAt: 'desc' },
});
// todos is the server's authoritative answer ([] = server has no matching rows)

Offline / timeout policy. If the client is offline or the settle wait times out (5000 ms by default), the behavior depends on allowLocal:

  • Default (allowLocal unset/false): rejects with QueryOnceUnsettledError. There is no authoritative data, and queryOnce refuses to invent one.

    import { QueryOnceUnsettledError } from '@topgunbuild/client';
    
    try {
      const rows = await client.queryOnce<Todo>('todos', { where: { completed: false } });
    } catch (err) {
      if (err instanceof QueryOnceUnsettledError) {
        // err.code === 'QUERY_ONCE_UNSETTLED'
        // err.reason is 'offline' or 'timeout'
        // No authoritative server data — do NOT treat as empty.
      }
    }
  • { allowLocal: true }: still does not resolve with local data on the happy path — instead it throws a typed QueryOnceLocalError carrying the non-settled local snapshot on .localData. This keeps the distinction explicit: a normal resolve is always settled server data, while a caught QueryOnceLocalError is always non-settled local data. You opt in by catching it.

    import { QueryOnceLocalError } from '@topgunbuild/client';
    
    try {
      const rows = await client.queryOnce<Todo>(
        'todos',
        { where: { completed: false } },
        { allowLocal: true },
      );
      // rows: settled server data
    } catch (err) {
      if (err instanceof QueryOnceLocalError) {
        // err.code === 'QUERY_ONCE_LOCAL_FALLBACK'
        // err.reason is 'offline' or 'timeout'
        // err.localData: QueryResultItem<Todo>[] — the non-settled local snapshot
      }
    }

The default timeout is exported as DEFAULT_QUERY_ONCE_TIMEOUT_MS (5000). Override per call with opts.timeoutMs.

One-shot (queryOnce) vs live (query / useQuery)

  • Reach for queryOnce when you need a single authoritative snapshot and want to know for certain whether the server answered: data exports, validation/decision logic, AI-agent reads, server-confirmed checks. You handle offline/timeout explicitly via the typed errors above.
  • Reach for the live query handle (or React useQuery) when you want a continuously-updating view that should render instantly from the local cache and reconcile as the server responds — lists, feeds, dashboards, anything the user watches. Use the subscribe { settled } flag if a particular frame needs to distinguish local-optimistic from server-confirmed.

topic(name)

public topic(name: string): TopicHandle

Returns a pub/sub topic for ephemeral fan-out messaging (not CRDT-persisted). See Live notifications for the canonical hook-first pattern.

const chat = client.topic('chat-room');
chat.publish({ text: 'Hello!' });
const unsubscribe = chat.subscribe((msg) => {
  console.log(msg);
});

getLock(name)

public getLock(name: string): DistributedLock

Returns a distributed lock handle. See Counters & locks for fencing-token semantics and single-node-vs-cluster caveats.

const lock = client.getLock('resource-A');
if (await lock.lock(10000)) {
  // Critical section
  await lock.unlock();
}

DistributedLock methods: lock(ttl?: number), unlock(), isLocked().

getPNCounter(name)

public getPNCounter(name: string): PNCounterHandle

Returns a PN-Counter (positive-negative) for offline-capable increment/decrement. See Counters & locks for a canonical example.

const likes = client.getPNCounter('likes:post-123');
likes.increment();
likes.decrement();
likes.addAndGet(10);
likes.subscribe((value) => {
  console.log('Current likes:', value);
});

Entry processors — planned (v2.x)

Server-side atomic read-modify-write via user-defined functions (client.executeOnKey, client.executeOnKeys, BuiltInProcessors) requires a WASM sandbox on the v2.x roadmap. The SDK surface throws an explanatory error pre-launch — see /docs/roadmap.

close()

public async close(): Promise<void>

Tears down the client. Awaits cluster reconnect-timer cleanup so the host process does not leak setTimeout handles.

Search and SQL

search(mapName, query, options?)

public async search<T>(
  mapName: string,
  query: string,
  options?: { limit?: number; minScore?: number; boost?: Record<string, number> }
): Promise<Array<{ key: string; value: T; score: number; matchedTerms: string[] }>>

One-shot BM25 full-text search. Requires FTS enabled for the map on the server.

const results = await client.search<Article>('articles', 'machine learning', {
  limit: 20,
  minScore: 0.5,
  boost: { title: 2.0, body: 1.0 },
});

searchSubscribe(mapName, query, options?)

public searchSubscribe<T>(
  mapName: string,
  query: string,
  options?: SearchOptions
): SearchHandle<T>

Live BM25 subscription. The handle receives ENTER/UPDATE/LEAVE deltas as documents change.

const handle = client.searchSubscribe<Article>('articles', 'machine learning', {
  limit: 20,
  minScore: 0.5,
});
const unsubscribe = handle.subscribe((results) => setResults(results));
handle.dispose();

sql(query)

public async sql(query: string): Promise<SqlQueryResult>

Execute a SQL query server-side via DataFusion. Map names are table names. Requires the server’s DataFusion feature and registered schemas.

const result = await client.sql('SELECT name, age FROM users WHERE age > 21 ORDER BY age');
// result.columns: ['name', 'age']
// result.rows: [[...], [...]]

vectorSearch(mapName, queryVector, options?)

public async vectorSearch(
  mapName: string,
  queryVector: Float32Array | number[],
  options?: VectorSearchClientOptions
): Promise<VectorSearchClientResult[]>

ANN search against the HNSW vector index. Query vector serializes as little-endian f32 bytes.

const results = await client.vectorSearch('notes', new Float32Array([0.1, 0.2, 0.3]), { k: 5 });

hybridSearch(mapName, queryText, options?)

public async hybridSearch(
  mapName: string,
  queryText: string,
  options?: HybridSearchClientOptions
): Promise<HybridSearchClientResult[]>

Tri-hybrid search combining exact match, full-text, and semantic vector results via Reciprocal Rank Fusion.

hybridSearchSubscribe(mapName, queryText, options?)

public hybridSearchSubscribe<T = unknown>(
  mapName: string,
  queryText: string,
  options?: HybridSearchSubscribeOptions
): HybridSearchHandle<T>

Live tri-hybrid search subscription.

hybridQuery(mapName, filter?)

public hybridQuery<T>(mapName: string, filter: HybridQueryFilter = {}): HybridQueryHandle<T>

Combine FTS predicates with traditional filter predicates in a single query. Results include _score for FTS ranking.

import { Predicates } from '@topgunbuild/core';

const handle = client.hybridQuery<Article>('articles', {
  predicate: Predicates.and(
    Predicates.match('body', 'machine learning'),
    Predicates.equal('category', 'tech')
  ),
  sort: { _score: 'desc' },
  limit: 20,
});

Connection State

getConnectionState()

public getConnectionState(): SyncState

Returns the current state from the connection state machine (e.g., OFFLINE, CONNECTING, AUTHENTICATING, READY).

onConnectionStateChange(listener)

public onConnectionStateChange(listener: (event: StateChangeEvent) => void): () => void

Subscribe to state transitions. Returns an unsubscribe function.

const unsubscribe = client.onConnectionStateChange((event) => {
  console.log(`State: ${event.from} -> ${event.to}`);
});

getStateHistory(limit?)

public getStateHistory(limit?: number): StateChangeEvent[]

Returns the ring-buffered state-change history (useful for debugging reconnect storms).

resetConnection()

public resetConnection(): void

Reset the connection and state machine. Use after fatal errors to start fresh.

Backpressure

getPendingOpsCount()

public getPendingOpsCount(): number

Number of unacknowledged operations waiting for server ack.

getBackpressureStatus()

public getBackpressureStatus(): BackpressureStatus

Snapshot: { pending, maxPending, strategy, paused, droppedCount }.

isBackpressurePaused()

public isBackpressurePaused(): boolean

True when the writer is currently paused under the pause strategy.

onBackpressure(event, listener)

public onBackpressure(
  event: 'backpressure:high' | 'backpressure:low' | 'backpressure:paused' | 'backpressure:resumed' | 'operation:dropped',
  listener: (data?: BackpressureThresholdEvent | OperationDroppedEvent) => void
): () => void

Subscribe to backpressure transitions.

client.onBackpressure('backpressure:high', ({ pending, max }) => {
  console.warn(`Warning: ${pending}/${max} pending ops`);
});
client.onBackpressure('backpressure:paused', () => showLoadingSpinner());
client.onBackpressure('backpressure:resumed', () => hideLoadingSpinner());

Event Journal

getEventJournal()

public getEventJournal(): EventJournalReader

Returns the journal reader for subscribing to and replaying map-change events (audit trail, undo history, change-feed consumers).

const journal = client.getEventJournal();

const unsubscribe = journal.subscribe((event) => {
  console.log(`${event.type} on ${event.mapName}:${event.key}`);
});

// Filtered subscription
journal.subscribe(
  (event) => console.log('User changed:', event.key),
  { mapName: 'users' }
);

// Historical replay
const events = await journal.readFrom(0n, 100);

EventJournalReader methods: readFrom(sequence, limit), readMapEvents(...), getLatestSequence(), subscribe(listener, options?).

Conflict Resolvers

getConflictResolvers()

public getConflictResolvers(): ConflictResolverClient

Observe merge rejections (the built-in CRDT merge logic rejected a remote change). The returned client’s register / unregister / list methods throw — registering custom server-side resolvers requires a WASM sandbox on the v2.x roadmap, see /docs/roadmap.

const resolvers = client.getConflictResolvers();

resolvers.onRejection((rejection) => {
  console.log(`Merge rejected: ${rejection.reason}`);
});

Per-Record Sync State

getRecordSyncStateTracker()

public getRecordSyncStateTracker(): RecordSyncStateTracker

The tracker projects op-log mutations, connection state, and merge rejections into a four-state tag per (mapName, key). Used by React hooks (useSyncState, the *WithSyncState companions) and by advanced consumers reading sync state outside a query context.

Cluster Mode API

isCluster()

public isCluster(): boolean

True when the client was constructed with cluster config.

getConnectedNodes()

public getConnectedNodes(): string[]

List of currently connected node IDs (cluster mode); empty array otherwise.

getPartitionMapVersion()

public getPartitionMapVersion(): number

Current partition-map version; 0 in single-server mode.

isRoutingActive()

public isRoutingActive(): boolean

True when direct routing to partition owners is active.

getClusterHealth()

public getClusterHealth(): Map<string, NodeHealth>

Per-node health map.

refreshPartitionMap()

public async refreshPartitionMap(): Promise<void>

Force a partition-map refresh. Useful after detecting routing errors.

getClusterStats()

public getClusterStats(): { mapVersion: number; partitionCount: number; nodeCount: number; lastRefresh: number; isStale: boolean } | null

Router statistics snapshot.

Authentication

setAuthToken(token)

public setAuthToken(token: string): void

Set a static JWT for the next sync handshake.

client.setAuthToken('jwt-token-here');

setAuthTokenProvider(provider)

public setAuthTokenProvider(provider: () => Promise<string | null>): void

Provider is called on each reconnection — refresh tokens land here.

client.setAuthTokenProvider(async () => {
  const token = await refreshToken();
  return token;
});

For Clerk/Firebase/BetterAuth/Custom, prefer the auth constructor option with a pluggable AuthProvider instead of calling these methods directly.


← Reference Overview · Core →