noboil

base factory (external API cache)

Keyed external-data cache with TTL, refresh, invalidate, and stale-while-revalidate. For TMDB lookups, Gravatar avatars, OpenWeather data — anything fetched from an upstream API.

Use base (wired via cacheCrud) for tables that mirror an external API: TMDB movies, Gravatar avatars, OpenWeather forecasts. The factory keys rows by an upstream id, calls your fetcher on cache miss, optionally refreshes in the background, and exposes invalidate/purge for manual control. There's no per-user ownership — the cache is global.

API surface

type CacheCrudResult = {
  get: Query<(ctx, { key: string }) => Row | null>
  list: Query<(ctx, { paginationOpts }) => PaginatedResult>
  load: Action<(ctx, { key: string }) => Row>
  refresh: Action<(ctx, { key: string }) => Row>
  invalidate: Mutation<(ctx, { key: string }) => { invalidated: boolean }>
  purge: Mutation<(ctx, { keys?: string[] }) => { deleted: number }>
}
MethodAuthSemantics
get(key)publicReturns cached row or null (does not fetch). Cheap query.
load(key)publicFetches if missing, returns row. Idempotent — multiple concurrent calls dedupe.
refresh(key)publicRe-fetches even if cached, updates row, returns latest.
invalidate(key)role-gatedMarks row stale (next load re-fetches).
purge({keys})role-gatedDeletes specified rows (or all if no keys).
list({paginationOpts})role-gatedCursor-paginated full read — admin dashboards.
const { tables, reducers } = makeCacheCrud(spacetimedb, {
  tableName: 'movie',
  keyField: 'tmdbId',
  fields,
  fetcher: async (ctx, key) => fetchTmdb(key)
})
ReducerAuthParamsSemantics
load_moviepublickeyFetch + insert if missing.
refresh_moviepublickeyRe-fetch + update.
invalidate_movierole-gatedkeyMark stale.
purge_movierole-gatedkeys[]?Delete specified rows or all.

Reads happen via subscription on tables.movie — the hook calls load_movie on miss.

Schema

import { object, string, number, array } from 'zod/v4'
import { schema, makeBase } from 'noboil/convex/schema'

const base = makeBase({
  movie: object({
    tmdbId: number(),
    title: string(),
    overview: string(),
    posterPath: string().nullable(),
    releaseDate: string(),
    voteAverage: number(),
    genres: array(object({ id: number(), name: string() }))
  })
})
import { object, string, number, array } from 'zod/v4'
import { schema, makeBase } from 'noboil/spacetimedb/schema'

const base = makeBase({
  movie: object({
    tmdbId: number(),
    title: string(),
    overview: string(),
    posterPath: string().nullable(),
    releaseDate: string(),
    voteAverage: number()
  })
})

Auto-fields injected:

  • key: string — primary identifier (the cache key, e.g. TMDB id as string)
  • updatedAt?: number — last refresh timestamp (used for TTL checks)
  • _creationTime — Convex native (when first cached)

The cache is keyed by upstream id — no automatic indexes are needed beyond the primary key.

Wire it up

// backend/convex/convex/movie.ts
import { TMDB } from 'tmdb-ts'
import { cacheCrud } from '../lazy'
import { s } from '../s'

const tmdb = new TMDB(process.env.TMDB_KEY!)
const c = cacheCrud({
  table: 'movie',
  schema: s.movie,
  key: 'tmdbId',
  ttl: 86_400_000,             // 24h freshness
  staleWhileRevalidate: true,  // serve stale while refreshing
  fetcher: async (_ctx, tmdbId) => {
    const m = await tmdb.movies.details({ movie_id: Number(tmdbId) })
    return toMovie(m)
  }
})
export const { all, get, load, refresh, invalidate, purge } = c

cacheCrud is called directly (not through tables: ({ table }) => ...) because the fetcher needs side effects only available in actions.

// backend/spacetimedb/src/index.ts
const spacetimedb = noboil({
  tables: ({ table }) => ({
    movie: table(s.movie, {
      key: 'tmdbId',
      ttl: 86_400_000,
      fetcher: async (_, tmdbId) => fetchTmdb(tmdbId)
    })
  })
})

Options reference

OptionTypeEffect
keystringField name to use as cache key (typically the upstream id field).
fetcher(ctx, key) => Promise<T>Called on cache miss or refresh. Must return a complete row matching the schema.
ttlnumber (ms)If updatedAt + ttl < now, treat as stale on next load.
staleWhileRevalidatebooleanReturn stale row immediately, refresh in background. Reduces latency.
hooksCacheHooks<T>onFetch(data) to transform fetched data before insert.
rateLimitnumber | { max, window }Per-caller limit on refresh calls (load is naturally bounded by ttl).

Stale-while-revalidate

ttl: 60_000,                 // 1 minute freshness window
staleWhileRevalidate: true   // return stale, refresh in background

First load after staleness returns the stale row immediately so the UI never blocks; the cache is updated in the background. Next load returns fresh.

Without staleWhileRevalidate, stale load blocks on the fetcher (slower but always-fresh).

Hooks

hooks: {
  onFetch: async (raw) => {
    // mutate fetched data before storing
    return { ...raw, normalizedTitle: raw.title.toLowerCase() }
  }
}

Useful for derived fields, schema normalization, or compressing fields you don't need.

Client hooks

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

const { data, isLoading, refresh, invalidate } = useCacheEntry({
  query: api.movie.get,
  load: api.movie.load,
  refresh: api.movie.refresh,
  invalidate: api.movie.invalidate,
  key: tmdbId
})

useCacheEntry calls load if data is null and exposes refresh/invalidate for manual control.

import { useCacheEntry, useMut } from 'noboil/spacetimedb/react'

const { data, isLoading } = useCacheEntry(tables.movie, tmdbId, {
  load: reducers.load_movie,
  refresh: reducers.refresh_movie
})

When to use base vs others

  • base — keyed by upstream id; no per-user state; fetcher knows how to populate. TMDB, Gravatar.
  • kv — keyed by string but globally meaningful (banner, feature flag). No fetcher. See kv factory.
  • singleton — exactly one row per user. See singleton factory.
  • owned — user-owned, no external source. See owned factory.

Demo

web/cvx/movie and web/stdb/movie wrap TMDB: search by query, click a result to load(tmdbId), render details. Cache hit on subsequent loads; refresh button forces re-fetch. Both backends.

On this page