Kynetra FX

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.

NameTypeDescription
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.

examples/kv.ts
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 tests
const clock = fixedClock(0)
const kvTest = inMemoryKv(clock)
await kvTest.set('x', 'val', { ttlMs: 500 })
;(clock as { _now: number })._now = 1000
console.log(await kvTest.get('x')) // null — expired
src/app.ts
import { 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.

NameTypeDescription
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.

examples/cache.ts
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')) // null

Tip

Use 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.

NameTypeDescription
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.

examples/store.ts
import { inMemoryStore } from '@kynetra/fx'
 
interface Post {
id: string
title: string
authorId: string
published: boolean
}
 
const store = inMemoryStore()
 
// Upsert
await 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 ID
const post = await store.get<Post>('posts', 'p1')
console.log(post?.title) // 'Hello world'
 
// List all published posts
const published = await store.list<Post>('posts', { published: true })
console.log(published.length) // 1
 
// Update (put replaces)
await store.put<Post>('posts', { ...post!, published: true })
 
// Delete
await store.delete('posts', 'p2')
src/routes/posts.ts
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.

src/routes/posts.test.ts
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

The 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:

src/worker.ts
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.