Adapter API
Storage adapters allow TopGun to persist data to various backends.
The IStorageAdapter interface is used by both the Client (for offline support) and the Server (for durability).
Interface Definition
interface IStorageAdapter {
// Lifecycle
initialize(dbName: string): Promise<void>;
close(): Promise<void>;
waitForReady?(): Promise<void>; // Optional - for async adapters
// Key-Value Operations
get<V>(key: string): Promise<LWWRecord<V> | ORMapRecord<V>[] | any | undefined>;
put(key: string, value: any): Promise<void>;
remove(key: string): Promise<void>;
batchPut(entries: Map<string, any>): Promise<void>;
// Metadata (for internal system state)
getMeta(key: string): Promise<any>;
setMeta(key: string, value: any): Promise<void>;
// Operation Log (for sync)
appendOpLog(entry: Omit<OpLogEntry, 'id'>): Promise<number>;
getPendingOps(): Promise<OpLogEntry[]>;
markOpsSynced(lastId: number): Promise<void>;
// Iteration
getAllKeys(): Promise<string[]>;
} IDBAdapter (IndexedDB)
The IDBAdapter is designed for true “memory-first” behavior. It initializes IndexedDB in the background while allowing immediate use of the database.
Non-Blocking Initialization
IndexedDB can take 50-500ms to initialize. The IDBAdapter queues write operations in memory and replays them once IndexedDB is ready. This means zero blocking time for your UI.
import { TopGun } from '@topgunbuild/client';
// No loading state needed!
const db = new TopGun({
sync: 'wss://example.com',
persist: 'indexeddb'
});
// Start using immediately - writes are queued in memory
db.todos.set({ id: '1', text: 'Hello' });
// Optional: wait for IndexedDB if needed
await db.waitForReady(); Behavior Summary
| Operation | When Not Ready | When Ready |
|---|---|---|
put, remove, setMeta | Queued in memory | Executed immediately |
get, getMeta, getAllKeys | Waits for ready | Executed immediately |
appendOpLog | Queued in memory | Executed immediately |
Implementing a Custom Adapter
To create a custom adapter, implement the interface above. Ensure that:
- Atomic Writes:
batchPutshould ideally be atomic. - Ordered OpLog:
appendOpLogmust return a strictly increasing ID or sequence number. - Persistence: Data must survive process restarts.
class MyCustomAdapter implements IStorageAdapter {
async initialize(dbName: string) {
// Open database connection
}
async get(key: string) {
// Return value or undefined
}
// ... implement other methods
} PostgresAdapter
@topgunbuild/server, not a client adapter. It’s documented here for completeness.Constructor
new PostgresAdapter(config, options?)Parameters
configPoolConfig | Pool. PostgreSQL connection configuration object or an existing pg Pool instance.options?PostgresAdapterOptions. Optional configuration for the adapter.
Options
interface PostgresAdapterOptionsParameters
tableName?string. Custom table name for storing data. Defaults to 'topgun_maps'. Must contain only alphanumeric characters and underscores, and start with a letter or underscore.
Basic Usage
import { PostgresAdapter } from '@topgunbuild/server';
const adapter = new PostgresAdapter({
host: 'localhost',
port: 5432,
database: 'myapp',
user: 'postgres',
password: 'secret'
});
await adapter.initialize(); Custom Table Name
Use the tableName option when you need multiple TopGun instances sharing the same database, or to follow your organization’s naming conventions.
import { PostgresAdapter } from '@topgunbuild/server';
import { Pool } from 'pg';
// Using custom table name
const adapter = new PostgresAdapter(
{ connectionString: process.env.DATABASE_URL },
{ tableName: 'my_app_data' }
);
// Or with an existing Pool instance
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PostgresAdapter(pool, { tableName: 'my_app_data' }); With ServerCoordinator
import { ServerCoordinator } from '@topgunbuild/server';
import { PostgresAdapter } from '@topgunbuild/server';
const storage = new PostgresAdapter({
connectionString: process.env.DATABASE_URL
});
const server = new ServerCoordinator({
port: 8765,
storage
});
await server.ready(); EncryptedStorageAdapter
The EncryptedStorageAdapter provides transparent encryption for all data stored locally. It uses the Web Crypto API (AES-GCM) to encrypt data before writing to the underlying storage and decrypts it on read.
What It Protects
Protected Against
- Browser DevTools inspection
- Malicious browser extensions
- Physical device access (disk dumps)
- IndexedDB data export
Not Protected Against
- XSS attacks (if attacker has JS execution)
- Compromised application code
- Key theft from memory
Constructor
new EncryptedStorageAdapter(wrapped, key)Parameters
wrappedIStorageAdapter. The underlying storage adapter to wrap (e.g., IDBAdapter).keyCryptoKey. AES-256-GCM encryption key from Web Crypto API.
Basic Usage
import { TopGunClient, EncryptedStorageAdapter, IDBAdapter } from '@topgunbuild/client';
// 1. Create the base adapter (IndexedDB)
const baseAdapter = new IDBAdapter();
// 2. Wrap it with encryption
const encryptedAdapter = new EncryptedStorageAdapter(baseAdapter, encryptionKey);
// 3. Use in TopGun client
const client = new TopGunClient({
serverUrl: 'wss://example.com',
storage: encryptedAdapter
}); Creating Encryption Keys
The adapter requires a CryptoKey for AES-256-GCM encryption. Here are two common approaches:
Password-Derived Key (PBKDF2)
Best for user-authenticated encryption where the key is derived from a password:
// Derive encryption key from user password using PBKDF2
async function deriveKeyFromPassword(password: string, salt: Uint8Array): Promise<CryptoKey> {
const encoder = new TextEncoder();
// Import password as key material
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey']
);
// Derive AES-256-GCM key
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false, // not extractable
['encrypt', 'decrypt']
);
}
// Usage
const salt = crypto.getRandomValues(new Uint8Array(16));
// Store salt somewhere (localStorage, alongside encrypted data, etc.)
localStorage.setItem('encryption_salt', btoa(String.fromCharCode(...salt)));
const key = await deriveKeyFromPassword(userPassword, salt); Device-Bound Random Key
Best for protecting data from disk dumps without requiring user password:
// Generate a random encryption key (device-bound)
async function generateEncryptionKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // extractable for storage
['encrypt', 'decrypt']
);
}
// Export key for storage
async function exportKey(key: CryptoKey): Promise<string> {
const exported = await crypto.subtle.exportKey('raw', key);
return btoa(String.fromCharCode(...new Uint8Array(exported)));
}
// Import key from storage
async function importKey(keyString: string): Promise<CryptoKey> {
const keyData = Uint8Array.from(atob(keyString), c => c.charCodeAt(0));
return crypto.subtle.importKey(
'raw',
keyData,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
// Usage: Store key in localStorage (protects against disk dumps)
let key: CryptoKey;
const storedKey = localStorage.getItem('topgun_encryption_key');
if (storedKey) {
key = await importKey(storedKey);
} else {
key = await generateEncryptionKey();
localStorage.setItem('topgun_encryption_key', await exportKey(key));
}