Skip to Content
prxy.monster v1 is in early access. See what shipped →
SdkStorage access

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 Postgres+Redis+R2 (cloud) or SQLite+memory+filesystem (local).

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.

Backends:

  • Cloud: Upstash Redis (HTTP REST).
  • Local: in-memory Map<string, { value, expiresAt }> with a 30-second sweep.

ctx.storage.db — relational + vector

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

The QueryBuilder is Supabase-style 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 (Postgres ON CONFLICT) 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, anything you’d put in Postgres normally.

Backends:

  • Cloud: Postgres (Neon) + pgvector (vector(1536), ivfflat index).
  • Local: better-sqlite3 + sqlite-vec when available. Falls back to a JSON-cosine scan when sqlite-vec isn’t compiled.

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.

Backends:

  • Cloud: Cloudflare R2.
  • Local: filesystem under ~/.prxy/blob/{key}.

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:

  • KV unavailableget returns null, set swallows error and logs.
  • DB unavailable — queries return { data: null, error }. Calling code should treat as no-op.
  • Blob unavailableget returns null, put throws (writes are usually critical, reads are usually optional).

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

Last updated on