Read this first — what's shipped vs. planned
Shipped today (single-node): JWT auth, per-map ACL booleans (read/write), JWT roles claim extraction, LWW-Map and OR-Map CRDTs, IndexedDB offline storage, WebSocket realtime sync, full-text search (tantivy), HLC-ordered pub/sub topics. Planned (not yet available): First-class RBAC role abstraction, map-pattern security policies (users:*, public:*), field-level allow-lists, cluster mTLS, Raft-replicated multi-region. See /docs/roadmap for the full maturity matrix.
Migrating from Firebase
This guide is for Firestore users, Firebase Realtime Database users, and anyone considering leaving Firebase due to vendor lock-in, per-MAU cost surprises, or the inability to write data offline without waiting for a round-trip. TopGun maps most Firebase data primitives directly — with local-first reads and writes, CRDT conflict resolution, and a self-hosted OSS alternative to Firebase’s proprietary platform. Before diving in, cross-check any Firebase feature you rely on against the Concept Mapping table below and the roadmap to confirm it is available today.
Concept Mapping
| Firebase concept | TopGun equivalent | Notes |
|---|---|---|
| Firestore Document | client.getMap(‘users’).get(userId) | Local-first; no network round-trip on read |
| Firestore Collection | client.getMap(‘users’) | Server-side full-text and SQL-style queries available |
onSnapshot() realtime listener | client.query(name, filter).subscribe(callback) (vanilla JS) / useQuery() React hook | Server-pushed deltas via WebSocket; filter-aware — mirrors Firebase onSnapshot query semantics |
serverTimestamp() | Hybrid Logical Clock (automatic, server-sanitized) | HLCs are causality-aware; no clock-skew bugs |
Batched write / runTransaction | Implicit batching in SyncEngine outbox | Writes coalesce automatically into a single OpBatch over WebSocket; no explicit batch API needed |
| Last-write-wins conflict | LWW-Map merge by HLC + node-id tiebreak | Deterministic across clients; no “lost update” surprises |
| Set-add / set-remove | OR-Map (Observed-Remove) | Concurrent adds/removes via ORMap never lose data |
Realtime Database ref.on(‘value’) | client.getMap(name).onChange(callback) | Local LWWMap change notification — fires on any write to the map (local or synced); callback receives no arguments, caller reads getMap().get(key) for current value |
Presence (onDisconnect) | Server release_on_disconnect API for locks/topics/counters | Single-node only today; cluster presence wiring is planned (see /docs/roadmap) |
Offline persistence (enableIndexedDbPersistence) | First-class via IDBAdapter | Always-on; reads/writes never wait for network |
| Firestore Security Rules | JWT auth + per-map ACL read/write booleans | Pattern-based rules (users/{userId}) on roadmap — see /docs/roadmap |
| Cloud Functions onWrite trigger | Not yet shipped — Entry Processor is design-only (see /docs/roadmap) | No current server-side equivalent; see /docs/roadmap for planned delivery |
| Per-MAU billing | None — self-hosted, OSS (Apache-2.0) | Cost = your infra; no usage-based pricing |
| Vendor lock-in | None — open source, MsgPack wire protocol, plain Postgres durable store | Migrate off TopGun via pg_dump |
Side-by-Side Code Patterns
Pattern A — Initialize and authenticate
import { initializeApp } from 'firebase/app';
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';
const app = initializeApp({
apiKey: '...',
authDomain: 'my-app.firebaseapp.com',
projectId: 'my-app',
});
const auth = getAuth(app);
await signInWithEmailAndPassword(
auth,
'user@example.com',
'password'
);import { TopGunClient } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';
const client = new TopGunClient({
serverUrl: 'wss://topgun.example.com',
storage: new IDBAdapter(),
});
// JWT issuance is handled by your own auth server.
// See /docs/guides/authentication for integration options.
const jwt = await myAuthServer.signIn(email, password);
client.setAuthToken(jwt);TopGun does not issue JWTs itself. Bring your own auth provider (BetterAuth, Clerk, Firebase Auth, or any OIDC-compatible issuer) and pass the resulting JWT via client.setAuthToken(). See the Authentication guide for step-by-step setup.
Pattern B — Read, write, and subscribe to realtime updates
import {
getFirestore,
doc,
getDoc,
setDoc,
onSnapshot,
} from 'firebase/firestore';
const db = getFirestore(app);
const ref = doc(db, 'users', userId);
// Read — requires a network round-trip on cache miss
const snap = await getDoc(ref);
console.log(snap.data());
// Write
await setDoc(ref, { name: 'Alice', age: 30 });
// Realtime subscription
const unsub = onSnapshot(ref, (snap) => {
console.log(snap.data());
});import { TopGunClient, Predicates } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';
const client = new TopGunClient({
serverUrl: 'wss://topgun.example.com',
storage: new IDBAdapter(),
});
// Read — synchronous, local-first (no await)
const record = client.getMap('users').get(userId);
console.log(record);
// Write — synchronous, local-first; syncs in background
client.getMap('users').set(userId, { name: 'Alice', age: 30 });
// Realtime subscription (vanilla JS):
// QueryHandle.subscribe receives server-pushed deltas,
// filtered server-side — mirrors onSnapshot query semantics.
const query = client.query('users', {
predicate: Predicates.equal('id', userId),
});
const unsub = query.subscribe((results) => {
console.log(results);
});
// React shortcut — wraps the same QueryHandle:
// const results = useQuery('users', { where: ... });
// Raw local-change notifications (no filter):
const unsubLocal = client.getMap('users').onChange(() => {
const record = client.getMap('users').get(userId);
console.log(record);
});TopGun reads and writes are synchronous because data lives in local memory first. The SyncEngine syncs changes to the server in the background. The client.query().subscribe() path delivers server-pushed, filter-aware deltas — the closest equivalent to Firestore’s onSnapshot query listener. client.getMap().onChange() is a lower-level notification that fires on any write to the local LWWMap (local or synced), with no filter; the callback receives no arguments and the caller reads the current value by calling get().
If you’re using React
import { useQuery, useMutation } from '@topgunbuild/react';
import { type UserProfile } from './schema';
function UserCard({ userId }: { userId: string }) {
const { data: users } = useQuery<UserProfile>('users');
const { update } = useMutation<UserProfile>('users');
const profile = users.find(u => u._key === userId);
const setOnline = () => update(userId, { name: profile?.name ?? '', status: 'online' });
return <button onClick={setOnline}>{profile?.name}</button>;
}
Pattern C — Batched writes
import { getFirestore, writeBatch, doc } from 'firebase/firestore';
const db = getFirestore(app);
const batch = writeBatch(db);
batch.set(doc(db, 'users', 'alice'), { name: 'Alice' });
batch.set(doc(db, 'users', 'bob'), { name: 'Bob' });
batch.set(doc(db, 'posts', 'p1'), { title: 'Hello' });
// Explicit commit required
await batch.commit();// Multiple .set() calls are automatically coalesced
// by the SyncEngine outbox into a single OpBatch
// sent over WebSocket. No explicit batch API needed.
// CRDT semantics make per-key writes commutative.
client.getMap('users').set('alice', { name: 'Alice' });
client.getMap('users').set('bob', { name: 'Bob' });
client.getMap('posts').set('p1', { title: 'Hello' });
// All three writes are batched automatically
// and delivered as one OpBatch to the server.The same auto-batching applies to multiple useMutation .create() / .update() calls inside a React render or event handler.
Deployment Mode Semantics
| Deployment | Status | Notes |
|---|---|---|
| Single-node (recommended for migration) | Stable | All Firestore/RTDB capabilities mapped above are production-ready in single-node mode |
| Cluster (current) | Partition-routing, no Raft | Safe when one node owns the partition; weaker guarantees under node failure — see /docs/roadmap |
| Cluster (planned) | Raft-replicated — planned | Multi-region with QUORUM/STRONG consistency on roadmap — see /docs/roadmap |
For a first migration, start on single-node. The partition-routed cluster mode works but does not yet have Raft-backed consensus; see the roadmap for cluster-safety status.
Why migrate?
- Local-first reads/writes: Operations never wait for network; the UI updates immediately. Compare to Firestore’s
getDoc, which requires a network round-trip on a cache miss. - CRDT conflict resolution: LWW-Map and OR-Map merge deterministically via HLC. No “lost update” surprises when two clients write the same key offline.
- Self-hosted, OSS (Apache-2.0): Run TopGun on your own cloud, your own Postgres. No proprietary platform, no vendor agreement required.
- Plain Postgres durable store: Migrate data off TopGun with
pg_dump. Your data is always in a table you can query directly. - Performance: 483K ops/sec fire-and-forget, ~37K ops/sec fire-and-wait at 1.5ms p50 on M1 Max (200 connections). See benchmarks for methodology.
Migration checklist
- Audit which Firebase features you use — Firestore, Realtime Database, Authentication, Cloud Functions, Storage, etc.
- Cross-reference each feature against the Concept Mapping table above.
- Flag any planned-only items (Cloud Functions trigger, map-pattern security rules, cluster mTLS) against /docs/roadmap and decide whether to wait or work around.
- Port auth: replace Firebase Auth with a JWT issuer of your choice and configure
client.setAuthToken(). See Authentication. - Port reads/writes one collection at a time: replace
doc()/getDoc()/setDoc()withgetMap().get()/getMap().set(), and replaceonSnapshotwithclient.query().subscribe()(vanilla JS) oruseQuery()(React). - Backfill existing data via a one-time import script that reads from Firestore and writes to TopGun via the client SDK or direct Postgres insert.