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 }>
}| Method | Auth | Semantics |
|---|---|---|
get(key) | public | Returns cached row or null (does not fetch). Cheap query. |
load(key) | public | Fetches if missing, returns row. Idempotent — multiple concurrent calls dedupe. |
refresh(key) | public | Re-fetches even if cached, updates row, returns latest. |
invalidate(key) | role-gated | Marks row stale (next load re-fetches). |
purge({keys}) | role-gated | Deletes specified rows (or all if no keys). |
list({paginationOpts}) | role-gated | Cursor-paginated full read — admin dashboards. |
const { tables, reducers } = makeCacheCrud(spacetimedb, {
tableName: 'movie',
keyField: 'tmdbId',
fields,
fetcher: async (ctx, key) => fetchTmdb(key)
})| Reducer | Auth | Params | Semantics |
|---|---|---|---|
load_movie | public | key | Fetch + insert if missing. |
refresh_movie | public | key | Re-fetch + update. |
invalidate_movie | role-gated | key | Mark stale. |
purge_movie | role-gated | keys[]? | 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 } = ccacheCrud 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
| Option | Type | Effect |
|---|---|---|
key | string | Field 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. |
ttl | number (ms) | If updatedAt + ttl < now, treat as stale on next load. |
staleWhileRevalidate | boolean | Return stale row immediately, refresh in background. Reduces latency. |
hooks | CacheHooks<T> | onFetch(data) to transform fetched data before insert. |
rateLimit | number | { 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 backgroundFirst 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.
singleton factory (one row per user)
Exactly one row per authenticated user — get + upsert. For profiles, preferences, settings, onboarding state.
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.