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-kv

No 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 descriptor

Every 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 execute

Four 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 execute

Resetting 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 survived

The 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 next has/get. The cursor has no TTL — permanent until deleted.
  • Tenant namespacingctx.kv keys are app-global; the facade never namespaces for you. Every key folds in ctx.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-UPDATE is exactly what ctx.kv replaces — one durable key, no schema, no migration. Reach for a table only when the data is relational and queried.
  • Caching a cursor. ctx.cache evicts for capacity; a cache miss on your sync watermark silently rewinds the sync (or double-processes). Durable state that a miss corrupts belongs in ctx.kv, never the cache.
  • Forgetting the tenant in the key. ctx.kv keys are app-global. A bare sync:cursor collides across tenants — always fold subject.tenantId (or the actor) into the key.
  • Storing large or unbounded values in KV. ctx.kv is 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.