noboil

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
})
ReducerAuthParamsSemantics
set_siteConfigrole-gatedkey, ...payload, expectedUpdatedAt?Upsert with optional conflict check.
rm_siteConfigrole-gatedkeyHard or soft delete.
restore_siteConfigrole-gatedkeyClear deletedAt (when softDelete: true).

Reads via subscription on tables.siteConfig.

MethodAuthSemantics
get(key)publicReturns row or null. Safe from unauthenticated clients.
list({paginationOpts})role-gatedCursor-paginated full read. Admin dashboards.
set(key, payload, {expectedUpdatedAt?})role-gatedUpsert with optional conflict check; throws CONFLICT if updatedAt mismatch.
rm(key)role-gatedSoft-delete (when softDelete: true) or hard-delete.
restore(key)role-gatedClears 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 value
  • writeRole: (ctx) => boolean | Promise<boolean> — predicate gating set/rm/list/restore. Sugar: literal true for public writes, omit for internal-only.
  • keys?: readonly string[] — optional compile-time enum constraint; rejects unknown keys with INVALID_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

OptionTypeEffect
softDeletebooleanrm sets deletedAt; get/list filter it out; restore clears it. Without it, rm hard-deletes and restore is a no-op.
rateLimitnumber | RateLimitConfigPer-user sliding-window limit on writes.
hooksKvHooksbeforeUpdate / afterUpdate / beforeRm / afterRm.
cleanFilesbooleanIf 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 between

Optional — 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 ifUse singleton ifUse cache if
Key is a named string ('banner', 'google_oauth')One row per userKey is a fetched-from-upstream id with TTL
Writes are gated by roleWrites are by ownerWrites are by cron refresh
Reads are publicReads are user-scopedReads are lookups on upstream key
Examples: maintenance banner, feature flags, system healthUser preferences, profileTMDB 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.

On this page