noboil

singleton factory (one row per user)

Exactly one row per authenticated user — get + upsert. For profiles, preferences, settings, onboarding state.

Use singleton for tables where each user has exactly zero or one row: profiles, preferences, onboarding flags, settings. The factory exposes only get (returns the caller's row or null) and upsert (insert if missing, patch if present). No list, no delete-other-people's-row, no id-passing — the user identity is the implicit key.

API surface

type SingletonCrudResult = {
  get: Query<(ctx) => Row | null>
  upsert: Mutation<(ctx, payload: T) => Row>
}
MethodAuthSemantics
get()requiredReturns the caller's singleton row or null if not yet created.
upsert(payload)requiredInserts if no row, patches if present. Always sets userId = ctx.user._id and updatedAt = now().
const { tables, reducers } = makeSingleton(spacetimedb, {
  tableName: 'profile',
  fields,
  table
})
ReducerAuthParamsSemantics
upsert_profilerequired...payloadInsert if no row for sender, patch if present.

Reads via subscription on tables.profile filtered by userId === ctx.identity.

Schema

import { object, string, boolean, enum as zenum } from 'zod/v4'
import { schema, makeSingleton, file as fileSchema } from 'noboil/convex/schema'
const file = fileSchema()

const singleton = makeSingleton({
  profile: object({
    avatar: file.nullable().optional(),
    bio: string().max(500).optional(),
    displayName: string().trim().min(1),
    notifications: boolean(),
    theme: zenum(['light', 'dark', 'system'])
  }),
  preferences: object({
    onboarded: boolean(),
    locale: string().default('en')
  })
})
import { object, string, boolean, enum as zenum } from 'zod/v4'
import { schema, makeSingleton } from 'noboil/spacetimedb/schema'

const singleton = makeSingleton({
  profile: object({
    bio: string().max(500).optional(),
    displayName: string().trim().min(1),
    notifications: boolean(),
    theme: zenum(['light', 'dark', 'system'])
  })
})

Auto-fields injected:

  • userId — primary key (one row per user)
  • updatedAt — last write timestamp
  • _creationTime (Convex) / createdAt (SpacetimeDB)

Index auto-created: by_user (unique).

Wire it up

// backend/convex/lazy.ts
const api = noboil({
  /* ... */
  tables: ({ table }) => ({
    profile: table(s.profile, {
      hooks: {
        beforeUpdate: async (ctx, payload) => {
          // sanitize, normalize, ...
          return { ...payload, displayName: payload.displayName.trim() }
        }
      }
    })
  })
})
// backend/convex/convex/profile.ts
import { api } from '../lazy'
export const { get, upsert } = api.profile
// backend/spacetimedb/src/index.ts
const spacetimedb = noboil({
  tables: ({ table }) => ({
    profile: table(s.profile)
  })
})

Options reference

OptionTypeEffect
rateLimitnumber | { max, window }Per-user limit on upsert.
hooksSingletonHooks<T>beforeUpdate(ctx, payload) / afterUpdate(ctx, { payload, prev }).

Singletons deliberately do not support softDelete, pub, acl, cascade, or search — by definition there's nothing to scope, share, or paginate. If you need any of those, you want owned instead.

Patterns

First-time use

get() returns null if the user has never upserted. Render an onboarding form when null:

const profile = useQuery(api.profile.get, {})
if (profile === undefined) return <Spinner />        // loading
if (profile === null) return <ProfileSetupForm />    // never set
return <ProfileEditor profile={profile} />            // existing

Patch semantics

upsert(payload) is a full replacement at the schema level — Zod validates the entire payload. To do a partial update, merge with current first:

const current = await api.profile.get({})
await api.profile.upsert({ ...current, theme: 'dark' })

Preferences split

For multiple unrelated settings (UI preferences, billing settings, notification settings), use multiple singletons rather than one giant blob:

const singleton = makeSingleton({
  uiPrefs:    object({ theme: ..., locale: ... }),
  billPrefs:  object({ plan: ..., autoRenew: ... }),
  notifPrefs: object({ email: ..., push: ... })
})

Each gets its own get/upsert pair — partial updates without read-modify-write cycles.

Client hooks

import { useSingleton } from 'noboil/convex/react'

const { data, upsert } = useSingleton(api.profile)
if (data === undefined) return <Spinner />
if (data === null) return <SetupForm onSubmit={upsert} />
return <Editor profile={data} onSave={upsert} />
import { useSingleton } from 'noboil/spacetimedb/react'
import { useSpacetimeDB } from 'spacetimedb/react'

const { identity } = useSpacetimeDB()
const { data, upsert, isLoading } = useSingleton(
  { table: tables.profile, upsert: reducers.upsert_profile },
  identity
)

When to use singleton vs others

  • singleton — one row per user, simple get/upsert. Profile, preferences.
  • owned — multiple rows per user. See owned factory.
  • kv — string-keyed but global (not per-user). Site config, banners. See kv factory.

Demo

web/{cvx,stdb}/blog, /org, and /poll each ship a Profile page using singleton (blogProfile, orgProfile, pollProfile) — first-time setup form, edit existing, avatar upload (Convex). Both backends.

On this page