# {{projectName}} / {{appName}}

Voltro backend scaffold (template: **api-kv**).

The **durable key-value** template. It demonstrates `ctx.kv` — the framework's
storage primitive for state you **can't recompute** — on a small
**external-event-sync** domain: a sync **cursor** (a watermark) and TTL-bounded
**idempotency markers** that dedupe redelivered events. It boots with **zero
infrastructure** (`store: 'memory'`).

## The one idea

Three storage primitives, three contracts — pick by what a loss costs you:

| Primitive | Holds | If you lose it | Backend default |
|---|---|---|---|
| `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
have, so a cache (which evicts) is wrong and a hand-managed table row is
overkill. This template shows why.

## Boot

```bash
pnpm install                                  # at the repo root
pnpm --filter @{{projectName}}/{{appName}} dev
# → http://localhost:4000
# → ws://localhost:4000/ws
```

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. To put **just the KV** on Redis (a
persistent one), set `kv: 'redis'` / `KV_BACKEND=redis` — the handler code does
not change.

## What it demonstrates

| File | Primitive | `ctx.kv` surface |
|---|---|---|
| `app.config.ts`                        | app config | zero-infra boot; the optional `kv:` / `cache:` backend knobs. |
| `database/schema.ts`                   | schema     | the `synced_events` rows (store) — deliberately NOT the cursor. |
| `actions/sync.pull.action.*`           | action     | **`getOrElse`** (cursor) → **`has`** / **`set(…, { ttlMs })`** (markers) → **`set`** (advance cursor). |
| `actions/sync.status.action.*`         | action (Effect) | **`yield* Kv`** — `getOrElse` (cursor) + **`list`** (count live markers). |
| `actions/sync.reset.action.*`          | action     | **`delete`** (cursor) + **`list`** / **`delete`** (markers). |
| `queries/events.list.query.*`          | query      | reactive stream of the synced ROWS (store, for contrast). |

## The end-to-end flow

1. **`sync.pull`** reads the durable cursor with `getOrElse` (default `0` on the
   first-ever call), pulls the next page from the (simulated) upstream, and for
   each event: `has(marker)` → skip if seen, else `store.insert` the row +
   `set(marker, { ttlMs })`. Finally it `set`s the cursor to the new watermark.
2. **`sync.status`** reads the cursor and `list`s the live markers.
3. **`sync.reset`** `delete`s the cursor. A following `sync.pull` re-reads from
   `0` but **skips everything** — the markers survive independently, so
   idempotency holds across a cursor rewind. Pass `{ markers: true }` to also
   drop the markers and force a full re-ingest.

## Try it

Over the rpc surface (a `voltro dev` web client, `POST /rpc`, or the inspect
`invoke` endpoint):

```jsonc
{ "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
```

Watch the synced rows stream in live via the `events.list` subscription (the
dashboard's data tab, or any web client).

## 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 — it's permanent until deleted.
- **Tenant namespacing** — `ctx.kv` keys are app-global; the facade never
  namespaces for you. Every key here folds in `ctx.request.subject.tenantId`
  (`sync:${tenantId}:…`) so two tenants' cursors/markers never collide.

## Notes

- All primitives are **auto-discovered by file convention** — nothing is
  registered in `app.config.ts`.
- `action` / `query` keep the **descriptor / executor split**: the browser-safe
  `*.ts` descriptor never imports `@voltro/database`, `@voltro/runtime`, or
  `node:*`; the `.server.ts` executor does the work.
- Durable KV has **two handler surfaces**, both wired in every runtime
  (`voltro dev` AND `voltro serve`): the async **`ctx.kv`** facade (used by
  `sync.pull` / `sync.reset`) and the Effect-native **`Kv`** service via
  `yield* Kv` (used by `sync.status`). They share one resolved store, so pick by
  whether your handler is async or an `Effect` — the state is identical.
