`connect.kv` is a small key/value store per workspace, backed by the Realtime
Database and reached only through Connect. Values are JSON up to 128 KB, keys
are `[A-Za-z0-9_-]` up to 64 characters.

Use it for what a document store is bad at: things you *fetch* rather than
*find*.

## The one semantic that matters

**Expiry is evaluated on read.** There is no sweep to wait for and no window
where a stale value is still served — the moment a key's TTL passes, it reads
as absent.

That is what makes cache-aside correct here without a version stamp or a
background job.

## Cache-aside

The pattern almost every use of this store reduces to:

```ts title="src/lib/cache.ts"
import type { ConnectClient } from "@cosmoops/connect-sdk/server";

export async function cached<T>(
  connect: ConnectClient,
  workspaceId: string,
  key: string,
  ttlSeconds: number,
  compute: () => Promise<T>,
): Promise<T> {
  try {
    const hit = await connect.kv.get({ workspaceId, namespace: "cache", key });
    return hit.value as T;
  } catch (error) {
    // 404 is a miss, not a failure. Anything else is a real problem and
    // should not be swallowed into a silent recompute on every request.
    if (!isNotFound(error)) throw error;
  }

  const value = await compute();
  await connect.kv.set({ workspaceId, namespace: "cache", key, value, ttlSeconds });
  return value;
}

function isNotFound(error: unknown): boolean {
  return typeof error === "object" && error !== null && (error as { status?: number }).status === 404;
}
```

```ts
const report = await cached(connect, workspaceId, `report-${month}`, 3600, () =>
  buildExpensiveReport(workspaceId, month),
);
```

<Callout type="warn" title="A miss is a 404, not an empty value">
  `get` throws on a missing or expired key. Catching *everything* turns a
  Connect outage into a recompute on every request — exactly when you can least
  afford it. Match on the status.
</Callout>

## Invalidation

There is no wildcard delete, deliberately — a prefix delete over a shared store
is an easy way to erase far more than intended.

Two patterns cover it:

**Delete the keys you know.** Correct when the set is small and enumerable.

```ts
await Promise.all(
  ["summary", "totals", "chart"].map((key) =>
    connect.kv.delete({ workspaceId, namespace: "cache", key }),
  ),
);
```

**Put a version in the key.** Correct when it is not. Bump the version and
every old key ages out on its own TTL.

```ts
const version = await currentReportVersion(workspaceId);
const key = `report-v${version}-${month}`;
```

The second scales and needs no cleanup. Prefer it once you have more than a
handful of derived keys.

## Rate limiting

```ts
export async function consumeRateLimit(
  connect: ConnectClient,
  workspaceId: string,
  bucket: string,
  limit: number,
  windowSeconds: number,
): Promise<boolean> {
  const key = `${bucket}-${Math.floor(Date.now() / 1000 / windowSeconds)}`;

  let used = 0;
  try {
    used = (await connect.kv.get({ workspaceId, namespace: "limits", key })).value as number;
  } catch {
    // First request in this window.
  }

  if (used >= limit) return false;
  await connect.kv.set({ workspaceId, namespace: "limits", key, value: used + 1, ttlSeconds: windowSeconds * 2 });
  return true;
}
```

The window is in the key, so a new window is a new key and the old one expires
itself. The TTL is *twice* the window so a request landing on a boundary still
finds its own counter.

<Callout title="This is approximate, on purpose">
  Read-then-write is not atomic, so two concurrent requests can both see the
  same count. That is fine for protecting an expensive endpoint from a runaway
  client, and wrong for anything you bill on — meter that through
  `billing.consumeAddon`, which debits transactionally.
</Callout>

## Feature flags as master data

A flag that is the same for every customer belongs in an **application-scoped**
namespace — one copy, owned by you, read by every tenant. Declare it that way
on the Store tab and omit `workspaceId`:

```ts
await connect.kv.set({
  namespace: "flags",
  key: "new-editor",
  value: { enabled: true, rollout: 0.25 },
});

const flag = await connect.kv.get({ namespace: "flags", key: "new-editor" });
```

Per-workspace overrides stay workspace-scoped, and the resolution order is
yours:

```ts
async function flag(connect: ConnectClient, workspaceId: string, key: string) {
  try {
    return (await connect.kv.get({ workspaceId, namespace: "overrides", key })).value;
  } catch {
    return (await connect.kv.get({ namespace: "flags", key })).value;
  }
}
```

Master data is written with your API key only — never on behalf of a user. A
write permission is something a *workspace* grants, so honouring one on shared
data would let one tenant's admin edit what every other tenant reads.

## Idempotency

```ts
const key = `webhook-${event.id}`;
try {
  await connect.kv.get({ workspaceId, namespace: "processed", key });
  return; // Already handled.
} catch {
  // Not seen yet.
}

await handleEvent(event);
await connect.kv.set({ workspaceId, namespace: "processed", key, value: true, ttlSeconds: 7 * 24 * 3600 });
```

Mark *after* the work, not before: marking first means a crash mid-handler
loses the event permanently. Marking after means a crash replays it, which is
what "at least once" already promised.

## When not to use KV

| Want | Use |
|---|---|
| Query by a field | `connect.data` |
| More than 128 KB | `connect.data`, or Drive |
| Exact counters you bill on | `billing.consumeAddon` |
| Something surviving a cache wipe | `connect.data` |

The dividing line: KV is for values you can **recompute**. If losing it loses
information, it belongs in the data store.

## Listing

```ts
let cursor: string | null = null;
do {
  const page = await connect.kv.list({ workspaceId, namespace: "cache", cursor });
  for (const entry of page.items) console.log(entry.key, entry.expiresAt);
  cursor = page.nextCursor;
} while (cursor);
```

<Callout type="warn" title="Stop on nextCursor, never on an empty page">
  Expired entries are filtered *after* paging, so a page can come back short —
  even empty — while more keys remain behind it.
</Callout>