Read this first — what's shipped vs. planned
TopGun does NOT replace Supabase's full BaaS. TopGun is a sync layer in front of YOUR Postgres — it does not replace Supabase Auth, Edge Functions, Storage buckets, or row-level security policies. TopGun ships per-map ACL booleans (read/write) plus JWT roles claim extraction; first-class RBAC and Postgres RLS-style policies are on the roadmap (see /docs/roadmap). Supabase postgres_changes (CDC from the Postgres write-ahead log) maps loosely to TopGun's CRDT-write notifications, but TopGun owns the write path — clients write through the TopGun client, not directly to Postgres. Reference: /docs/roadmap.
Migrating from Supabase Realtime
Supabase Realtime is a Postgres-native real-time library: clients open WebSocket channels, receive broadcast events, track presence, and listen to postgres_changes derived from the write-ahead log. TopGun is an offline-first sync layer that sits in front of YOUR Postgres — clients read and write locally, and the TopGun server merges and persists. This guide maps Supabase Realtime primitives one-to-one onto TopGun and names the gaps along the way.
Concept Mapping
| Supabase Concept | TopGun Equivalent | Notes |
|---|---|---|
Channel (supabase.channel(‘room-1’)) | client.topic(‘room-1’) | Named pub/sub topic; HLC-ordered delivery |
Broadcast event (channel.send({type:‘broadcast’})) | topic.publish(payload) / topic.subscribe(cb) | Fan-out to all subscribers of the topic |
Presence (channel.track(state) / channel.on(‘presence’, …)) | release_on_disconnect (single-node only) | Cross-client presence broadcast is on the roadmap; release_on_disconnect is a lock-cleanup primitive |
postgres_changes event | CRDT write notification via client.query().subscribe() | TopGun owns the write path; notifications are emitted from CRDT merge, not Postgres WAL |
| Row Level Security (RLS) policies | Per-map ACL booleans (read/write) | Full RLS-style policies on roadmap — see /docs/roadmap |
Supabase Auth (signInWithPassword, etc.) | BYO JWT issuer + client.setAuthToken(jwt) | TopGun does not issue JWTs; pair with BetterAuth, Clerk, Supabase Auth, or any OIDC issuer |
| Supabase Edge Functions | Not in scope | Use your own server endpoints (Node, Bun, Deno, Go, Rust, etc.) |
| Supabase Storage buckets | Not in scope | Continue using Supabase Storage, S3, or any object store independently |
createClient(url, anonKey) | new TopGunClient({ serverUrl, storage }) | Single client entry point; storage adapter pluggable (IndexedDB in browsers) |
Client packages (@supabase/realtime-js, supabase-js) | @topgunbuild/client | One package covers data + realtime; auth/storage are BYO |
| Pricing tiers (Free / Pro / Team / Enterprise) | Self-hosted, OSS (Apache-2.0) | Cost = your infra; no usage-based pricing |
| Postgres-native (your-own-Postgres framing) | TopGun also runs in front of YOUR Postgres | Parity — durable store is plain Postgres; pg_dump works |
| Offline writes | First-class via IDBAdapter | Not supported in Supabase Realtime; TopGun queues writes locally and syncs on reconnect |
Side-by-Side Code Patterns
Pattern A — Initialize and authenticate
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
'https://my-project.supabase.co',
'public-anon-key',
);
await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: '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, Supabase 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 — Channel-subscribe vs query-subscribe
Supabase Realtime exposes two listening shapes — broadcast (fan-out messages) and postgres_changes (CDC from the WAL). TopGun maps these onto two distinct primitives: pub/sub topics and CRDT-backed query subscriptions. Both pairs are shown below.
// Broadcast — fan-out messages
const channel = supabase.channel('room-1');
channel
.on('broadcast', { event: 'msg' }, (payload) => {
console.log('broadcast', payload);
})
.subscribe();
channel.send({
type: 'broadcast',
event: 'msg',
payload: { text: 'hello' },
});
// postgres_changes — CDC from the WAL
const todosChannel = supabase.channel('todos-changes');
todosChannel
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'todos' },
(payload) => {
console.log('row inserted', payload.new);
},
)
.subscribe();// Pub/sub topic — fan-out messages
const topic = client.topic('room-1');
const unsub = topic.subscribe((payload) => {
console.log('topic', payload);
});
topic.publish({ text: 'hello' });
// CRDT-backed query subscription
// Server-pushed deltas via WebSocket; emitted from
// CRDT merge, not from Postgres WAL.
const query = client.query('todos');
const unsubQuery = query.subscribe((results) => {
console.log('todos', results);
});Supabase delivers WAL-derived row events; TopGun delivers CRDT-merge deltas. Functionally similar for “show me changes to this collection,” but TopGun owns the write path — see Pattern C.
Pattern C — Write to Postgres + listen vs CRDT write via client
// Write goes through PostgREST (network round-trip).
await supabase.from('users').insert({
id: 'alice',
name: 'Alice',
age: 30,
});
// Listen for the change via postgres_changes
// (separate WebSocket subscription).
const channel = supabase.channel('users-changes');
channel
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'users' },
(payload) => {
console.log('row inserted', payload.new);
},
)
.subscribe();// Synchronous, local-first write. The CRDT merge
// propagates to the server (and to other clients
// subscribing via client.query) automatically.
client.getMap('users').set('alice', {
id: 'alice',
name: 'Alice',
age: 30,
});
// Listen via the same query subscription; merges
// from this client and others both arrive here.
const query = client.query('users');
const unsub = query.subscribe((results) => {
console.log('users', results);
});In Supabase, the write path is client → PostgREST → Postgres → WAL → Realtime → subscribers. In TopGun, the write path is client (local LWWMap) → SyncEngine → server → CRDT merge → subscribers, and the durable Postgres write happens server-side. Clients do not write to Postgres directly.
If you’re using React
import { useQuery, useMutation } from '@topgunbuild/react';
import { type User } from './schema';
function UserList() {
const { data: users } = useQuery<User>('users');
const { create } = useMutation<User>('users');
const add = () => create(crypto.randomUUID(), { name: 'Alice', age: 30 });
return (
<>
<button onClick={add}>Add User</button>
<ul>{users.map(u => <li key={u._key}>{u.name}</li>)}</ul>
</>
);
}
Deployment Mode Semantics
| Deployment | Status | Notes |
|---|---|---|
| Single-node (recommended for migration) | Stable | All Supabase Realtime 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. Supabase Realtime is read-only on the client; writes still go through PostgREST + a network round-trip.
- Offline persistence: First-class via
IDBAdapter. Supabase Realtime has no offline mode. - CRDT auto-merge: LWW-Map and OR-Map merge deterministically via HLC. No “lost update” surprises when two clients write the same key offline.
- Your-own-Postgres: Parity with Supabase’s “you own your Postgres” framing. TopGun’s durable store is plain Postgres;
pg_dumpworks. - 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 Supabase Realtime features in use (channels, broadcast, presence,
postgres_changes, RLS). - Cross-reference each against the Concept Mapping table above; mark mappings vs gaps.
- Audit which Supabase services you use beyond Realtime (Auth, Storage, Edge Functions, RLS) — TopGun replaces only the Realtime + sync surface; the others stay on Supabase or move to alternative providers. Make explicit decisions per service.
- Port the client layer: replace
createClient(url, anonKey)+ the auth flow withnew TopGunClient({ serverUrl, storage })+setAuthToken(BYO JWT). See Authentication. - Port channels/queries one resource at a time: replace
channel.on('broadcast', ...)withclient.topic(name).subscribe(cb), replacechannel.on('postgres_changes', ...)withclient.query().subscribe(), and replace directsupabase.from().insert()withclient.getMap().set()(the write path moves through TopGun, not directly to Postgres). - Backfill data via a one-time migration script that reads from Postgres (or via
supabase-js) and writes to TopGun via the client SDK or direct Postgres insert (TopGun owns the write path going forward).