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
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)

Browser storage adapter with non-blocking initialization. The default for browser environments.

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.

Non-Blocking Usage
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

OperationWhen Not ReadyWhen Ready
put, remove, setMetaQueued in memoryExecuted immediately
get, getMeta, getAllKeysWaits for readyExecuted immediately
appendOpLogQueued in memoryExecuted immediately

Implementing a Custom Adapter

To create a custom adapter, implement the interface above. Ensure that:

  • Atomic Writes: batchPut should ideally be atomic.
  • Ordered OpLog: appendOpLog must return a strictly increasing ID or sequence number.
  • Persistence: Data must survive process restarts.
Custom Adapter Example
class MyCustomAdapter implements IStorageAdapter {
  async initialize(dbName: string) {
    // Open database connection
  }

  async get(key: string) {
    // Return value or undefined
  }

  // ... implement other methods
}

PostgresAdapter

Note: PostgresAdapter is a server-side adapter from @topgunbuild/server, not a client adapter. It’s documented here for completeness.
Production-ready adapter for PostgreSQL databases. Recommended for server-side deployments.

Constructor

new PostgresAdapter(config, options?)

Parameters

  • config
    PoolConfig | Pool. PostgreSQL connection configuration object or an existing pg Pool instance.
  • options?
    PostgresAdapterOptions. Optional configuration for the adapter.

Options

interface PostgresAdapterOptions

Parameters

  • 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

PostgresAdapter Setup
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.

Custom Table Name
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

Pass the adapter to ServerCoordinator for persistent server storage.
Server with PostgreSQL
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

Client-side encryption for data at rest. Wraps any storage adapter with AES-256-GCM encryption.

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

  • wrapped
    IStorageAdapter. The underlying storage adapter to wrap (e.g., IDBAdapter).
  • key
    CryptoKey. AES-256-GCM encryption key from Web Crypto API.

Basic Usage

Using EncryptedStorageAdapter
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:

Password-Derived Key
// 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:

Device-Bound Key
// 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));
}

Key Management Warning

If the encryption key is lost, all encrypted data becomes irrecoverable. Consider implementing key backup strategies for password-derived keys, or accept data loss on device change for device-bound keys.

Learn More

For detailed security considerations and best practices, see the Client-Side Encryption section in the Security Guide.