Documentation
KV, Cache & Store
KvPort, CachePort, and StorePort cover the three most common storage shapes: a raw string key/value store with TTL, a typed cache with TTL, and a multi-collection document store. All three ship with in-memory fakes that obey the same contract as production adapters.
KvPort — string key/value with TTL#
KvPort is the lowest-level storage port. Values are always strings — ideal for session tokens, feature-flag overrides, or any data you serialize yourself.
| Name | Type | Description |
|---|---|---|
| get(key) | (string) → Promise<string | null> | Returns the value or null if the key does not exist or has expired. |
| set(key, value, opts?) | (string, string, { ttlMs?: number }?) → Promise<void> | Stores the value. When ttlMs is set the key expires after that many milliseconds. |
| delete(key) | (string) → Promise<void> | Removes the key. No-op if absent. |
| list(prefix?) | (string?) → Promise<string[]> | Returns all keys matching the prefix (or all keys if omitted). |
inMemoryKv
inMemoryKv(clock?) returns a KvPort backed by a Map. Pass a ClockPort to control time — useful for testing TTL expiry without sleeping.
import { inMemoryKv, fixedClock } from '@kynetra/fx' // Default: uses Date.now()const kv = inMemoryKv()await kv.set('greeting', 'hello')console.log(await kv.get('greeting')) // 'hello' // With TTL (1 second)await kv.set('token', 'abc123', { ttlMs: 1_000 })console.log(await kv.get('token')) // 'abc123'// ... 1001 ms later ...console.log(await kv.get('token')) // null // Frozen clock for deterministic testsconst clock = fixedClock(0)const kvTest = inMemoryKv(clock)await kvTest.set('x', 'val', { ttlMs: 500 });(clock as { _now: number })._now = 1000console.log(await kvTest.get('x')) // null — expiredimport { createFX } from '@kynetra/fx'import { inMemoryKv } from '@kynetra/fx-ports' const app = createFX()app.decorateContext('kv', inMemoryKv()) app.post('/cache', async (ctx) => { const kv = ctx.get('kv') const { key, value, ttl } = await ctx.jsonBody<{ key: string; value: string; ttl?: number }>() await kv.set(key, value, ttl ? { ttlMs: ttl * 1000 } : undefined) return ctx.status(204).response()}) app.get('/cache/:key', async (ctx) => { const kv = ctx.get('kv') const value = await kv.get(ctx.params.key) if (value === null) return ctx.status(404).json({ error: 'not found' }) return ctx.json({ value })})CachePort — typed generic cache with TTL#
CachePort is the typed sibling of KvPort. The get and set methods are generic, so you avoid manual JSON serialization in your handlers. Serialization is an implementation detail left to each adapter.
| Name | Type | Description |
|---|---|---|
| get<T>(key) | (string) → Promise<T | null> | Returns the typed value, or null on miss or expiry. |
| set<T>(key, value, opts?) | (string, T, { ttlMs?: number }?) → Promise<void> | Stores any serializable value with an optional TTL in milliseconds. |
| delete(key) | (string) → Promise<void> | Removes the entry. No-op if absent. |
inMemoryCache
inMemoryCache(clock?) stores values as-is in a Map. The clock argument behaves identically to inMemoryKv.
import { inMemoryCache } from '@kynetra/fx' interface UserProfile { id: string name: string plan: 'free' | 'pro'} const cache = inMemoryCache<UserProfile>() await cache.set('user:42', { id: '42', name: 'Alice', plan: 'pro' }, { ttlMs: 60_000 }) const profile = await cache.get<UserProfile>('user:42')if (profile) { console.log(profile.plan) // 'pro'} await cache.delete('user:42')console.log(await cache.get('user:42')) // nullTip
CachePort for computed values (rendered HTML fragments, expensive API responses, user profile hydration). Use KvPort when the value is already a string — session tokens, nonces, raw flags.StorePort — document CRUD by collection#
StorePort is a lightweight document store. Records are plain objects that extend { id: string }. Collections are namespaced strings — no schema migration required. This port maps naturally to Cloudflare D1 (via d1Store), a Postgres table, or any NoSQL database.
| Name | Type | Description |
|---|---|---|
| get<T>(collection, id) | (string, string) → Promise<T | null> | Fetches a single record by ID from the named collection. |
| list<T>(collection, filter?) | (string, Partial<T>?) → Promise<T[]> | Lists all records in the collection, optionally filtered by shallow equality on the filter object. |
| put<T>(collection, record) | (string, T & { id: string }) → Promise<T> | Upserts a record. Creates if absent, replaces if present. |
| delete(collection, id) | (string, string) → Promise<void> | Removes the record. No-op if absent. |
inMemoryStore
inMemoryStore() holds a Map<string, Map<string, unknown>>. Each collection key maps to its own record map. Collections are created on first write.
import { inMemoryStore } from '@kynetra/fx' interface Post { id: string title: string authorId: string published: boolean} const store = inMemoryStore() // Upsertawait store.put<Post>('posts', { id: 'p1', title: 'Hello world', authorId: 'u1', published: false })await store.put<Post>('posts', { id: 'p2', title: 'Second post', authorId: 'u2', published: true }) // Get by IDconst post = await store.get<Post>('posts', 'p1')console.log(post?.title) // 'Hello world' // List all published postsconst published = await store.list<Post>('posts', { published: true })console.log(published.length) // 1 // Update (put replaces)await store.put<Post>('posts', { ...post!, published: true }) // Deleteawait store.delete('posts', 'p2')import { createFX } from '@kynetra/fx'import { inMemoryStore } from '@kynetra/fx-ports'import { randomId } from '@kynetra/fx-ports' interface Post { id: string; title: string; body: string; createdAt: number } const app = createFX()app.decorateContext('store', inMemoryStore())app.decorateContext('id', randomId) app.get('/posts', async (ctx) => { const store = ctx.get('store') const posts = await store.list<Post>('posts') return ctx.json(posts)}) app.post('/posts', async (ctx) => { const store = ctx.get('store') const id = ctx.get('id') const { title, body } = await ctx.jsonBody<{ title: string; body: string }>() const post: Post = { id: id.generate(), title, body, createdAt: Date.now() } await store.put('posts', post) return ctx.status(201).json(post)}) app.delete('/posts/:id', async (ctx) => { const store = ctx.get('store') await store.delete('posts', ctx.params.id) return ctx.status(204).response()})Testing with the fakes#
Because all three fakes implement the same interfaces as production adapters, tests are straightforward. No test doubles, no sinon spies on database clients.
import { describe, it, expect, beforeEach } from 'vitest'import { inMemoryStore } from '@kynetra/fx-ports'import app from './posts' describe('posts API', () => { beforeEach(() => { // Fresh store for every test — no bleed between cases app.decorateContext('store', inMemoryStore()) }) it('creates and retrieves a post', async () => { const create = await app.fetch(new Request('http://x/posts', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'Test', body: 'Content' }), })) expect(create.status).toBe(201) const post = await create.json() as { id: string } const get = await app.fetch(new Request(`http://x/posts`)) const list = await get.json() as { id: string }[] expect(list.some(p => p.id === post.id)).toBe(true) })})Note
filter argument to list() uses shallow equality — it matches records where every key in the filter equals the corresponding record field. For advanced queries, add a filtering helper in your service layer rather than extending the port.Swapping to production adapters#
On Cloudflare Workers, replace the fakes with the D1-backed adapters from @kynetra/fx-cloudflare:
import { cloudflare, d1Store } from '@kynetra/fx-cloudflare'import app from './app' return { fetch(request: Request, env: { DB: D1Database }, ctx: ExecutionContext) { // Replace the in-memory store with a real D1 adapter app.decorateContext('store', d1Store(env.DB)) return cloudflare(app).fetch(request, env, ctx) }}See the full list of available adapters and how to write your own on the Ports & adapters page.