KV
Your application's key/value store - small values addressed by namespace and key, per workspace.
Small values, addressed by namespace and key, per workspace. Backed by the Realtime Database, reached only through Connect.
Use it for what a document store is bad at: a feature flag, a cached computation, a rate-limit counter, a draft nobody needs to query. Anything you want to find rather than fetch belongs in data.
Namespaces are declared like collections
Same Store tab, same allowlist, same readPermission/writePermission. A kv namespace declares no queryableFields - there are no queries here, only keys.
Keys and values
Keys are [A-Za-z0-9_-], up to 64 characters. That is narrower than either store would accept on its own, and deliberately so: a / in a key would silently address a deeper path, and ., $, #, [, ] are illegal in a Realtime Database key outright.
Values are JSON, up to 128 KB. Larger belongs in data, or in Drive.
await connect.kv.set({
workspaceId,
namespace: "flags",
key: "new-billing-ui",
value: { enabled: true, rollout: 0.25 },
});
const flag = await connect.kv.get({ workspaceId, namespace: "flags", key: "new-billing-ui" });Expiry
ttlSeconds sets an expiry, up to a year; omit it and the value persists. Expiry is evaluated on read, so an expired key reads as absent the moment it expires - there is no sweep you have to wait for, and no window where a stale value is still served.
list pages a namespace in key order with cursor. Expired entries are filtered after paging, so a page can come back shorter than limit while still having a nextCursor - treat nextCursor as the only signal that more exist.
Writes and deletes are audited by key, never by value.
Declaring the namespace
Same Store tab in the Console as a collection, same allowlist. A kv namespace declares a name, a scope and the two permissions; there are no queryableFields, because there are no queries here - only keys.
| Field | For the examples below |
|---|---|
| Name | cache, flags, ratelimit |
| Scope | Per workspace |
readPermission / writePermission | A permission id from your catalog, or null |
One namespace per purpose, not one per application. list pages a whole namespace, so a single kv namespace holding flags, counters and cached blobs together is one you can never enumerate usefully.
Cache-aside
The pattern this store is shaped for. Note the 404: get throws for an absent or expired key, and that throw is the cache miss - there is no null to check for.
import { ConnectError, type ConnectClient } from "@cosmoops/connect-sdk/server";
const TTL_SECONDS = 15 * 60;
export async function getMonthlyReport(
connect: ConnectClient,
workspaceId: string,
month: string,
): Promise<Report> {
const key = `report-${month}`;
try {
const hit = await connect.kv.get({ workspaceId, namespace: "cache", key });
return Report.parse(hit.value);
} catch (error) {
// A miss is a 404. Anything else - a 403 on an undeclared namespace, a
// network fault - must not be swallowed into a silent recompute.
if (!(error instanceof ConnectError) || error.status !== 404) throw error;
}
const report = await computeMonthlyReport(workspaceId, month);
await connect.kv.set({
workspaceId,
namespace: "cache",
key,
value: report,
ttlSeconds: TTL_SECONDS,
});
return report;
}Build keys from ids and dates, never from user input: the character class is enforced, so a / is refused rather than silently addressing a deeper path.
A feature flag, read on the server
const flag = await connect.kv.get({
workspaceId,
namespace: "flags",
key: "new-billing-ui",
});
const { enabled } = FlagValue.parse(flag.value);This is a flag your application owns, per workspace. It is not the same question as "does their plan include this" - that is an entitlement, and it belongs to useConnectFeature / entitlements. A flag you can flip is a rollout; a capability you cannot is a purchase.
A rate limit
ttlSeconds is what makes this a window rather than a running total: the counter stops existing when the window ends, so nothing has to reset it.
const key = `send-${userId}`;
let used = 0;
try {
const entry = await connect.kv.get({ workspaceId, namespace: "ratelimit", key });
used = z.number().parse(entry.value);
} catch {
// No counter yet - this is the first send in the window.
}
if (used >= 100) throw new RateLimitedError("100 sends per hour.");
await connect.kv.set({
workspaceId,
namespace: "ratelimit",
key,
value: used + 1,
ttlSeconds: 3600,
});set replaces rather than increments, so two requests in the same millisecond can both read used and both write used + 1. That is acceptable for a courtesy limit and not for anything metering money - billing.consumeAddon debits atomically and is the right call there.
Paging a namespace
let cursor: string | null = null;
do {
const page = await connect.kv.list({ workspaceId, namespace: "flags", limit: 100, cursor });
for (const entry of page.items) apply(entry.key, entry.value);
cursor = page.nextCursor;
} while (cursor);The loop condition is nextCursor, never page.items.length. Expired entries are filtered after paging, so an empty page in the middle of a namespace is ordinary.
Endpoints
GET /api/v1/application/kv/{namespace}
List a key/value namespace
Scope kv:read · Rate limit 300 requests / min per key
Pages a namespace in key order.
Expired entries are filtered out after paging, so a page can come back shorter than limit while still having a nextCursor. Treat nextCursor as the only signal that more keys exist - never an empty page.
Errors
forbidden (403 - key missing kv:read, or the namespace is not declared).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
namespace | path | yes | |
workspaceId | query | yes | |
limit | query | no | |
cursor | query | no |
Responses
| Status | Meaning |
|---|---|
200 | A page of entries |
403 | forbidden |
429 | rate_limited |
Response body
| Field | Type | Required | Notes |
|---|---|---|---|
items | array | yes | |
nextCursor | any | yes |
GET /api/v1/application/kv/{namespace}/{key}
Read one key
Scope kv:read · Rate limit 300 requests / min per key
Reads one value. An expired key reads as 404 the moment it expires - expiry is evaluated on read, so there is no sweep to wait for.
Errors
not_found (404 - absent or expired), forbidden (403).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
namespace | path | yes | |
key | path | yes | |
workspaceId | query | yes |
Responses
| Status | Meaning |
|---|---|
200 | The entry |
403 | forbidden |
404 | not_found - absent or expired |
429 | rate_limited |
Response body
| Field | Type | Required | Notes |
|---|---|---|---|
key | string | yes | |
value | any | yes | |
expiresAt | any | yes | |
updatedAt | string | yes |
PUT /api/v1/application/kv/{namespace}/{key}
Write one key
Scope kv:write · Rate limit 300 requests / min per key
Sets a value, replacing whatever was there.
ttlSeconds sets an expiry up to a year; omit it (or send null) and the value persists. Values are JSON up to 128 KB - larger belongs in the data store, or Drive.
Errors
validation_error (400 - value too large, or an invalid key), forbidden (403).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
namespace | path | yes | |
key | path | yes |
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
workspaceId | any | no | |
value | any | yes | |
ttlSeconds | any | no |
Responses
| Status | Meaning |
|---|---|
200 | Written |
400 | validation_error |
403 | forbidden |
429 | rate_limited |
Response body
| Field | Type | Required | Notes |
|---|---|---|---|
key | string | yes | |
value | any | yes | |
expiresAt | any | yes | |
updatedAt | string | yes |
DELETE /api/v1/application/kv/{namespace}/{key}
Delete one key
Scope kv:write · Rate limit 300 requests / min per key
Deletes a key. Idempotent - deleting an absent key succeeds.
Errors
forbidden (403 - key missing kv:write, or the namespace is not declared).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
namespace | path | yes | |
key | path | yes | |
workspaceId | query | yes |
Responses
| Status | Meaning |
|---|---|
204 | Deleted |
403 | forbidden |
429 | rate_limited |