kv factory (string-keyed global state)
Named key → value state with role-gated writes, public reads, soft-delete + restore, conflict detection, and optional key whitelist. For site config, feature flags, banners, system status.
Use kv for state addressed by a string key, not tied to a specific user — maintenance banners, feature flags, system health, site-wide configuration. Differs from singleton (one row per user) and cache (TTL'd lookups by external id): kv has many rows, each identified by a string key chosen by the caller.
API surface
type KvFactoryResult = {
get: Query<(ctx, { key: string }) => Row | null>
list: Query<(ctx, { paginationOpts }) => PaginatedResult>
set: Mutation<(ctx, { key: string, payload: T, expectedUpdatedAt?: number }) => Row>
rm: Mutation<(ctx, { key: string }) => { deleted: boolean }>
restore: Mutation<(ctx, { key: string }) => Row>
}const { tables, reducers } = makeKv(spacetimedb, {
tableName: 'siteConfig',
fields,
table
})| Reducer | Auth | Params | Semantics |
|---|---|---|---|
set_siteConfig | role-gated | key, ...payload, expectedUpdatedAt? | Upsert with optional conflict check. |
rm_siteConfig | role-gated | key | Hard or soft delete. |
restore_siteConfig | role-gated | key | Clear deletedAt (when softDelete: true). |
Reads via subscription on tables.siteConfig.
| Method | Auth | Semantics |
|---|---|---|
get(key) | public | Returns row or null. Safe from unauthenticated clients. |
list({paginationOpts}) | role-gated | Cursor-paginated full read. Admin dashboards. |
set(key, payload, {expectedUpdatedAt?}) | role-gated | Upsert with optional conflict check; throws CONFLICT if updatedAt mismatch. |
rm(key) | role-gated | Soft-delete (when softDelete: true) or hard-delete. |
restore(key) | role-gated | Clears deletedAt to bring back a soft-deleted row. |
Schema
import { object, string, boolean, number } from 'zod/v4'
import { schema, makeKv } from 'noboil/convex/schema'
const kv = makeKv({
systemStatus: {
schema: object({
kind: string(),
valid: boolean(),
error: string().optional(),
checkedAt: number()
}),
writeRole: (ctx) => isInternal(ctx),
keys: ['google_oauth', 'convex_api', 'db_migrations'] as const
},
siteConfig: {
keys: ['banner', 'maintenance'] as const,
schema: object({ active: boolean(), message: string() }),
writeRole: (ctx) => ctx.user?.role === 'admin'
}
})import { object, string, boolean } from 'zod/v4'
import { schema, makeKv } from 'noboil/spacetimedb/schema'
const kv = makeKv({
siteConfig: {
keys: ['banner', 'maintenance'] as const,
schema: object({ active: boolean(), message: string() }),
writeRole: true
}
})Config:
schema— Zod shape for the valuewriteRole: (ctx) => boolean | Promise<boolean>— predicate gatingset/rm/list/restore. Sugar: literaltruefor public writes, omit for internal-only.keys?: readonly string[]— optional compile-time enum constraint; rejects unknown keys withINVALID_KEY.
Auto-fields injected by the factory:
key: string— primary identifier (indexed)updatedAt: number— last write timestamp (used for conflict detection)deletedAt?: number— set when soft-deleted_creationTime: number— Convex native
Index auto-created: by_key (unique).
Wire it up
// backend/convex/lazy.ts
const api = noboil({
/* ... */
tables: ({ table }) => ({
siteConfig: table(s.siteConfig, {
softDelete: true, // enables restore + delayed cleanup
rateLimit: 10, // 10 writes/min/user
hooks: {
beforeUpdate: async (ctx, { key, payload, prev }) => ({ ...payload, updatedBy: ctx.user._id }),
afterUpdate: async (ctx, { key, payload }) => {/* notify subscribers, audit */}
}
})
})
})// backend/convex/convex/siteConfig.ts (re-export RPC)
import { api } from '../lazy'
export const { get, list, restore, rm, set } = api.siteConfig// backend/spacetimedb/src/index.ts
const spacetimedb = noboil({
tables: ({ table }) => ({
siteConfig: table(s.siteConfig, { softDelete: true })
})
})Reducers set_siteConfig, rm_siteConfig, restore_siteConfig are auto-registered.
Options reference
| Option | Type | Effect |
|---|---|---|
softDelete | boolean | rm sets deletedAt; get/list filter it out; restore clears it. Without it, rm hard-deletes and restore is a no-op. |
rateLimit | number | RateLimitConfig | Per-user sliding-window limit on writes. |
hooks | KvHooks | beforeUpdate / afterUpdate / beforeRm / afterRm. |
cleanFiles | boolean | If schema has file fields, remove orphaned files on overwrite/delete. |
writeRole patterns
writeRole: true // public writes (rare)
writeRole: (ctx) => ctx.user?.role === 'admin' // admin-only
writeRole: (ctx) => isInternal(ctx) // from cron / internal action only
writeRole: async (ctx) => {
const user = await ctx.db.get(ctx.userId)
return user?.permissions.includes('manage_status') ?? false
}Omitting writeRole entirely → internal-only (only callable from internalMutations). Public reads always allowed.
Typed keys
keys: ['banner', 'version', 'maintenance'] as const
// set/get/rm now require key to be 'banner' | 'version' | 'maintenance'Prevents typos in client code and gives editor autocomplete. Omit keys to allow arbitrary strings.
Conflict detection
const current = await ctx.db.query(api.siteConfig.get, { key: 'banner' })
await ctx.db.mutation(api.siteConfig.set, {
key: 'banner',
payload: { active: true, message: 'updated' },
expectedUpdatedAt: current?.updatedAt
})
// throws CONFLICT if another writer updated banner in betweenOptional — omit expectedUpdatedAt for last-write-wins semantics.
Soft-delete + restore
With softDelete: true:
await api.siteConfig.set({ key: 'banner', payload: { active: true, message: 'hi' } })
await api.siteConfig.rm({ key: 'banner' }) // deletedAt = now
await api.siteConfig.get({ key: 'banner' }) // null (filtered)
await api.siteConfig.restore({ key: 'banner' }) // deletedAt = undefined
await api.siteConfig.get({ key: 'banner' }) // returns { active: true, message: 'hi' }Useful for "trash bin" UX, undo, or accidental-delete recovery without backup.
Client hooks
import { useKv } from 'noboil/convex/react'
const banner = useKv(api.siteConfig, 'banner')
banner.data // Row | null | undefined
await banner.update({ active: true, message: 'hello' })
await banner.update({ active: true, message: 'hi' }, { expectedUpdatedAt })
await banner.remove()
await banner.restore()import { useKv } from 'noboil/spacetimedb/react'
const banner = useKv(
{
table: tables.siteConfig,
set: reducers.set_siteConfig,
rm: reducers.rm_siteConfig,
restore: reducers.restore_siteConfig
},
'banner'
)
banner.data // Row | null
await banner.update({ active: true, message: 'hello' })
await banner.remove()
await banner.restore()When to use kv vs singleton vs cache
Use kv if | Use singleton if | Use cache if |
|---|---|---|
Key is a named string ('banner', 'google_oauth') | One row per user | Key is a fetched-from-upstream id with TTL |
| Writes are gated by role | Writes are by owner | Writes are by cron refresh |
| Reads are public | Reads are user-scoped | Reads are lookups on upstream key |
| Examples: maintenance banner, feature flags, system health | User preferences, profile | TMDB movie, GitHub repo, external API response |
Demo
The poll demo uses kv for the siteConfig.banner row: a "Site banner admin" panel saves/clears/restores it; a top-of-page <BannerDisplay/> reads it without auth and shows the message when active.
Log factory (append-only event log)
Append-only sequential tables with atomic seq allocation, idempotent appends, soft-delete + restore, bulk operations, and parent-scoped listing. For messaging, audit trails, activity feeds, event sourcing.
quota factory (sliding window)
Per-owner sliding-window rate limit primitive with check / record / consume semantics, hooks, and ergonomic React integration. For anti-spam, API throttling, ballot-stuffing prevention.