prxy.monster API-key BYOK is live. Start free

Storage access

Modules access persistent state through ctx.storage. The storage adapter exposes three sub-stores: KV, DB, and blob.

async pre(ctx) {
  // Quick KV
  await ctx.storage.kv.set('mykey', 'myvalue', 60);
 
  // Relational
  const result = await ctx.storage.db.from('users').select('*').eq('id', 'abc').maybeSingle();
 
  // Blob
  await ctx.storage.blob.put('attachments/foo.json', JSON.stringify({ ... }));
}

The same code runs against managed cloud storage or a local data volume. Modules code to the adapter, not to a vendor-specific backend.

ctx.storage.kv — key-value

interface KvStore {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ttlSeconds?: number): Promise<void>;
  setNx(key: string, value: string, ttlSeconds: number): Promise<boolean>;
  del(key: string): Promise<void>;
  ttl(key: string): Promise<number>;
}
MethodNotes
getReturns null on miss. Errors are swallowed and treated as misses.
setOptional TTL in seconds. No TTL = no expiry.
setNxAtomic set-if-not-exists. Use for distributed locks.
delNo-op if key doesn’t exist.
ttlSeconds until expiry. -1 if no TTL set. -2 if key doesn’t exist.

Best for: rate limit counters, hot caches, distributed locks (setNx), spend counters.

Cloud mode uses managed TTL storage. Local mode uses an in-process TTL store.

ctx.storage.db — relational + vector

interface Database {
  from(table: string): QueryBuilder;
  raw(sql: string, params?: unknown[]): Promise<unknown[]>;
}

The QueryBuilder is chainable:

// SELECT
const result = await ctx.storage.db
  .from('patterns')
  .select('id, content, score')
  .eq('user_id', ctx.apiKey.userId)
  .gte('success_rate', 0.6)
  .order('score', { ascending: false })
  .limit(5);
 
if (result.error) ctx.logger.error({ err: result.error }, 'patterns lookup failed');
const patterns = result.data as Array<{ id: string; content: string; score: number }>;
 
// INSERT
await ctx.storage.db.from('events').insert({
  id: randomUUID(),
  user_id: ctx.apiKey.userId,
  type: 'cache_miss',
});
 
// UPSERT
await ctx.storage.db.from('counters').upsert(
  { id: 'requests', value: 1 },
  { onConflict: 'id' },
);
 
// VECTOR SEARCH
const matches = await ctx.storage.db
  .from('embeddings')
  .vectorSearch('embedding', queryEmbedding, { limit: 10, minScore: 0.7 });
// → Array<{ score: number; data: unknown }>

Best for: patterns, cached embeddings, audit logs, and relational module state.

Cloud mode uses managed relational storage with vector search. Local mode uses a local database file and falls back to a linear cosine scan when native vector search is unavailable.

ctx.storage.blob — large content

interface BlobStore {
  put(key: string, content: Buffer | string): Promise<void>;
  get(key: string): Promise<Buffer | null>;
  delete(key: string): Promise<void>;
  list(prefix: string): Promise<string[]>;
}

Best for: compressed conversation archives, attachments, anything that doesn’t belong in a DB row.

Cloud mode uses managed object storage. Local mode writes under the configured local data volume.

Schema migrations

Built-in modules ship their migrations alongside the module. Custom modules must run their own. Two patterns:

Idempotent CREATE in init()

Simple, works for additions:

async init(storage) {
  await storage.db.raw(`
    CREATE TABLE IF NOT EXISTS my_state (
      id TEXT PRIMARY KEY,
      value TEXT NOT NULL,
      created_at TEXT DEFAULT CURRENT_TIMESTAMP
    )
  `);
}

Track applied migrations in your own table and run forward-only DDL. See apps/gateway/src/storage/migrations in the source for the pattern the built-ins use.

Failure handling

Storage operations are designed to fail gracefully:

The principle: a storage outage shouldn’t take the whole gateway offline. A semantic cache miss is acceptable. A 503 to the client is not.

See also