API · Key-value
Durable ctx.kv on an external-event-sync domain — a sync cursor you can't recompute plus TTL-bounded idempotency markers, contrasted with ctx.store (the rows) and ctx.cache. Zero-infra boot.
The template that exercises ctx.kv — the framework's storage primitive for state you can't recompute. Instead of another CRUD reference, it ships a cohesive external-event-sync domain: a durable cursor (a watermark past everything already pulled) and TTL-bounded idempotency markers that dedupe redelivered events. Around them sit the synced rows in ctx.store, so the template makes the store-vs-kv-vs-cache distinction concrete. It boots with zero infrastructure (store: 'memory'). Template id: api-kv.
Scaffold
voltro create-project acme --api=api-kvNo env or services required. On store: 'memory', ctx.kv runs on its database backend in-process — durable within the run, reset on restart. Switch app.config.ts to store: 'postgres' and the cursor + markers survive restarts and are shared across replicas.
The one idea
Three storage primitives, three contracts — pick by what a loss costs you:
| Primitive | Holds | If you lose it | Default backend |
|---|---|---|---|
ctx.cache |
recomputable derived views | free — recompute | memory (evicts for capacity) |
ctx.kv |
the cursor + idempotency markers | re-process / double-process | database (durable, never evicted) |
ctx.store |
the synced rows | re-fetchable from the source | your SQL store |
A cursor is the textbook ctx.kv case: it isn't derived from anything you still hold, so a cache (which evicts) would silently reset your sync, and a hand-managed table row is overkill. See durable key-value.
What ships
apps/acme/api/ # dir named by the app, not the template
├── app.config.ts # type:api, store:'memory', defineEnv
├── package.json
├── tsconfig.json
├── README.md
├── database/
│ └── schema.ts # actors + tenants (core) + a synced_events table
├── actions/
│ ├── sync.pull.action.ts # descriptor — pull a page of events
│ ├── sync.pull.action.server.ts # executor — getOrElse/has/set(ttl)/set
│ ├── sync.status.action.ts # descriptor — read-only view
│ ├── sync.status.action.server.ts # executor (Effect) — yield* Kv
│ ├── sync.reset.action.ts # descriptor — rewind the sync
│ └── sync.reset.action.server.ts # executor — delete + list/delete
└── queries/
├── events.list.query.ts # descriptor — reactive rows (store, for contrast)
└── events.list.query.server.ts # executor — table descriptorEvery primitive is auto-discovered by file convention — nothing is registered in app.config.ts. The durable KV lives in the framework's _voltro_kv table (created automatically); no schema of yours models the cursor.
Database — the rows, deliberately NOT the cursor
The schema declares the two framework core tables plus a synced_events table for the ingested rows. The cursor and markers are not modelled here — they're durable KV.
// database/schema.ts
import {
databaseHandle, id, integer, table, text, timestamp, type InferRow,
} from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'
// Core tables — required by the tenant / audit mixins.
export const actors = table('actors', {
id: id(), kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
displayName: text().nullable(), createdAt: timestamp().default('now'),
})
export const tenants = table('tenants', { id: id(), name: text(), createdAt: timestamp().default('now') })
// The events we ingest from the source — re-fetchable, so a table is their home.
export const syncedEvents = table('synced_events', {
id: id({ prefix: 'evt' }),
externalId: text(),
kind: text(),
payload: text(),
sequence: integer(),
})
.with(tenant()) // tenantId + audit columns, auto-stamped from the subject
.reactive() // events.list subscription wakes on every insert
export type SyncedEvent = InferRow<typeof syncedEvents>
export const database = databaseHandle({ actors, tenants, syncedEvents })Pull — the durable cursor + idempotency markers
sync.pull is the heart of the template. The descriptor declares the schema; the executor reads the durable cursor, pulls a page, dedupes each event against a TTL marker, inserts the new rows, and advances the cursor.
// actions/sync.pull.action.ts — descriptor (browser-safe)
import { defineAction } from '@voltro/protocol'
import { Schema } from 'effect'
export const syncPull = defineAction({
name: 'sync.pull',
input: Schema.Struct({
limit: Schema.optional(Schema.Int.pipe(Schema.greaterThan(0))),
}),
output: Schema.Struct({
pulled: Schema.Int, // newly ingested (marker written)
skipped: Schema.Int, // already seen within the idempotency window
cursor: Schema.Int, // the durable watermark after this call
}),
})// actions/sync.pull.action.server.ts — executor (default export)
import type { AppContext } from '@voltro/runtime'
// Stand-in for a real upstream — deterministic + pure so it boots with no
// network. A real handler would `fetch()` here.
const fetchSince = (sequence: number, limit: number) =>
Array.from({ length: limit }, (_unused, i) => {
const seq = sequence + i + 1
return {
externalId: `ext-${seq}`,
kind: seq % 3 === 0 ? 'updated' : 'created',
payload: JSON.stringify({ seq }),
sequence: seq,
}
})
const MARKER_TTL_MS = 24 * 60 * 60_000 // 24h idempotency window
const execute = async (input: { limit?: number }, ctx: AppContext) => {
// KV keys are app-global — fold the tenant in so two tenants never collide.
const tenantId = ctx.request.subject.tenantId ?? 'anon'
const cursorKey = `sync:${tenantId}:cursor`
const limit = Math.min(input.limit ?? 5, 50)
// Durable cursor — getOrElse writes the default (0) ONLY on a first-run miss.
const cursor = await ctx.kv.getOrElse<number>(cursorKey, () => 0)
let pulled = 0
let skipped = 0
let highest = cursor
for (const evt of fetchSince(cursor, limit)) {
const markerKey = `sync:${tenantId}:seen:${evt.externalId}`
if (await ctx.kv.has(markerKey)) {
skipped++ // the source redelivered it within the TTL window
} else {
await ctx.store.insert('synced_events', {
externalId: evt.externalId, kind: evt.kind,
payload: evt.payload, sequence: evt.sequence, tenantId,
})
await ctx.kv.set(markerKey, evt.sequence, { ttlMs: MARKER_TTL_MS })
pulled++
}
highest = Math.max(highest, evt.sequence)
}
await ctx.kv.set(cursorKey, highest) // advance the durable watermark
return { pulled, skipped, cursor: highest }
}
export default executeFour KV operations carry the whole flow: getOrElse reads the cursor (writing the 0 default only on a genuine first-run miss), has checks a marker without deserializing, set(…, { ttlMs }) writes a bounded idempotency marker, and set advances the cursor. The rows go to ctx.store because they're re-fetchable; the cursor + markers go to ctx.kv because they're not.
Status & reset — two handler surfaces
Durable KV is reachable two ways, both wired in every runtime (voltro dev AND
voltro serve): the async ctx.kv facade (sync.pull / sync.reset) and
the Effect-native Kv service via yield* Kv (sync.status). They share
one resolved store — pick by whether your handler is async or an Effect.
// actions/sync.status.action.server.ts — executor (Effect mode)
import { Effect } from 'effect'
import { Kv } from '@voltro/kv'
import type { AppContext } from '@voltro/runtime'
const execute = (_input: Record<string, never>, ctx: AppContext) =>
Effect.gen(function* () {
const kv = yield* Kv // the framework provides Kv to every handler runtime
const tenantId = ctx.request.subject.tenantId ?? 'anon'
const cursor = yield* kv.getOrElse(`sync:${tenantId}:cursor`, () => 0)
// list(prefix) → every live (non-expired) marker under it.
const markers = yield* kv.list(`sync:${tenantId}:seen:`)
return { cursor, activeMarkers: markers.length }
})
export default execute// actions/sync.reset.action.server.ts — executor
import type { AppContext } from '@voltro/runtime'
const execute = async (input: { markers?: boolean }, ctx: AppContext) => {
const tenantId = ctx.request.subject.tenantId ?? 'anon'
// delete(key) → boolean (did it exist?).
const cursorCleared = await ctx.kv.delete(`sync:${tenantId}:cursor`)
let markersCleared = 0
if (input.markers) {
// list(prefix) + delete() to sweep a set. `ctx.kv.clear()` would drop the
// app's ENTIRE KV namespace — too broad for one tenant's sync keys.
for (const key of await ctx.kv.list(`sync:${tenantId}:seen:`)) {
if (await ctx.kv.delete(key)) markersCleared++
}
}
return { cursorCleared, markersCleared }
}
export default executeResetting only the cursor leaves the markers alive — so a following sync.pull re-reads from the start but skips everything, proving the two KV concerns are independent. That is the template's punchline.
Try it
Over the rpc surface (a voltro dev web client, POST /rpc, or the inspect invoke endpoint):
{ "tag": "sync.pull", "input": { "limit": 5 } } // → { pulled: 5, skipped: 0, cursor: 5 }
{ "tag": "sync.pull", "input": { "limit": 5 } } // → { pulled: 5, skipped: 0, cursor: 10 }
{ "tag": "sync.status", "input": {} } // → { cursor: 10, activeMarkers: 10 }
{ "tag": "sync.reset", "input": {} } // → { cursorCleared: true, markersCleared: 0 }
{ "tag": "sync.pull", "input": { "limit": 5 } } // → { pulled: 0, skipped: 5, cursor: 5 } ← markers survivedThe synced rows stream in live via the events.list subscription.
TTL & tenant namespacing
- TTL — markers are written with
{ ttlMs }(a 24h idempotency window). Expiry is lazy: an expired marker reads as a miss and is dropped on the nexthas/get. The cursor has no TTL — permanent until deleted. - Tenant namespacing —
ctx.kvkeys are app-global; the facade never namespaces for you. Every key folds inctx.request.subject.tenantId(sync:${tenantId}:…) so two tenants' cursors/markers never collide.
When to use api-kv vs. the variants
| You need… | Pick |
|---|---|
| The smallest CRUD reference to extend | api-backend |
| Durable state you can't recompute (cursors, progress, idempotency) | api-kv |
| Workflows / triggers / schedules exercised end-to-end | api-durable |
| The advanced schema DSL + query caching | api-data-advanced |
Anti-patterns
- Modelling a cursor as a table row. A watermark you hand-
UPDATEis exactly whatctx.kvreplaces — one durable key, no schema, no migration. Reach for a table only when the data is relational and queried. - Caching a cursor.
ctx.cacheevicts for capacity; a cache miss on your sync watermark silently rewinds the sync (or double-processes). Durable state that a miss corrupts belongs inctx.kv, never the cache. - Forgetting the tenant in the key.
ctx.kvkeys are app-global. A baresync:cursorcollides across tenants — always foldsubject.tenantId(or the actor) into the key. - Storing large or unbounded values in KV.
ctx.kvis for small control state — cursors, flags, markers — not blobs or ever-growing lists. It's durable with no eviction, so bulk data never gets reclaimed; model relational/bulk data as a table instead.
Pairs well with
store: 'postgres'— the cursor + markers survive restarts and are shared across replicas.- Key-value backends — put just the KV on a persistent Redis with
KV_BACKEND=redis, no handler change.