Recipes
Real-world composition patterns — schema, backend, and frontend for each recipe.
Recipes index
30 recipes (auto-generated TOC):
- Recipes index
- Blog with Auth, File Upload, Pagination, and Search
- Org CRUD with ACL and Cascade Delete
- Cache with External API
- Soft Delete with Restore
- Real-Time Presence Tracking
- Real-Time Chat
- File Upload
- Multi-Step Onboarding Form
- Singleton Profile with File Upload
- Custom Queries Alongside CRUD (Convex)
- Bulk Operations with Progress
- Ownership Flags with useOwnRows
- Post-Mutation Workflows
- Typing Components with InferRow, InferCreate, InferUpdate
- Phantom Type Inference
- Global Error Type with Register
- Create and Update Forms with schemaVariants
- Typed Form Validation Errors with getFieldErrors
- Reducing Mutation Boilerplate with useMutation
- Toast Shorthand with useMutation
- Field Validation Error Toasts with toastFieldError
- Error Discrimination with SenderError._tag
- AutoForm — Zero-Layout Forms
- File Constraints in Schema
- Auto Conflict Detection
- Custom Error Codes
- Unified CRUD with useCrud + createApi
- Poll: kv banner + log votes + quota anti-spam
- JSDoc
@examplelibrary
Blog with Auth, File Upload, Pagination, and Search
Features: owned CRUD · file upload · rate limiting · search · where clauses
Schema
import { file, makeOwned } from 'noboil/convex/schema'
import { boolean, object, string, enum as zenum } from 'zod/v4'
const owned = makeOwned({
blog: object({
title: string().min(1),
content: string().min(3),
category: zenum(['tech', 'life', 'tutorial']),
published: boolean(),
coverImage: file().nullable().optional()
})
})Backend
export const {
create,
pub: { list, read, search },
rm,
update
} = crud('blog', owned.blog, {
rateLimit: { max: 10, window: 60_000 },
search: 'content'
})Frontend
import { useList } from 'noboil/convex/react'
import { Form, useForm } from 'noboil/convex/components'
const BlogPage = () => {
const { items, loadMore, status } = useList(api.blog.list, {
where: { published: true }
})
const searched = useList(api.blog.search, { query: 'react hooks' })
return (
<ul>
{items.map(b => (
<li key={b._id}>
{b.coverImageUrl && <img src={b.coverImageUrl} alt="" />}
<h2>{b.title}</h2>
</li>
))}
{status === 'CanLoadMore' && (
<button onClick={loadMore}>Load more</button>
)}
</ul>
)
}
const CreateBlog = () => {
const form = useForm({
schema: owned.blog,
onSubmit: async d => {
await create(d)
return d
}
})
return (
<Form
form={form}
render={({ Text, Choose, Toggle, File, Submit }) => (
<>
<Text name="title" />
<Text name="content" multiline />
<Choose name="category" />
<Toggle name="published" />
<File name="coverImage" accept="image/*" />
<Submit>Publish</Submit>
</>
)}
/>
)
}Schema
import { file, schema } from 'noboil/spacetimedb/schema'
import { boolean, object, string, enum as zenum } from 'zod/v4'
const s = schema({
owned: {
blog: object({
title: string().min(1),
content: string().min(3),
category: zenum(['tech', 'life', 'tutorial']),
published: boolean(),
coverImage: file().nullable().optional()
})
}
})
export { s }Backend module
import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table }) => ({
blog: table(s.blog, { pub: 'published', rateLimit: 10 })
}) })This generates create_blog, update_blog, and rm_blog reducers with auth, ownership, conflict detection, and rate limiting. pub: 'published' lets every client see published posts plus their own drafts.
Frontend
import { Form, useForm, useFormMutation } from 'noboil/spacetimedb/components'
import { useList } from 'noboil/spacetimedb/react'
import { useTable, useReducer } from 'spacetimedb/react'
import { reducers, tables } from '@/generated/module_bindings'
import { s } from '../../s'
const BlogPage = () => {
const [allBlogs, isReady] = useTable(tables.blog)
const { data, hasMore, loadMore } = useList(allBlogs, isReady, {
where: { published: true },
sort: { field: 'updatedAt', direction: 'desc' },
pageSize: 20
})
return (
<ul>
{data.map(b => <li key={b.id}>{b.title}</li>)}
{hasMore && <button onClick={loadMore}>Load more</button>}
</ul>
)
}
const CreateBlog = () => {
const createBlog = useReducer(reducers.create_blog)
const form = useFormMutation({
schema: s.blog,
mutate: d => createBlog(d),
toast: 'Created'
})
return (
<Form
form={form}
render={({ Text, Choose, Toggle, File, Submit }) => (
<>
<Text name="title" />
<Text name="content" multiline />
<Choose name="category" />
<Toggle name="published" />
<File name="coverImage" accept="image/*" />
<Submit>Publish</Submit>
</>
)}
/>
)
}Org CRUD with ACL and Cascade Delete
Features: org multi-tenancy · per-item ACL · soft delete · cascade · permission guard
Schema
import { makeOrgScoped } from 'noboil/convex/schema'
import { array, boolean, number, object, string, enum as zenum } from 'zod/v4'
const orgScoped = makeOrgScoped({
project: object({
name: string().min(1),
description: string(),
status: zenum(['active', 'archived']),
editors: array(zid('users')).optional()
}),
task: object({
projectId: zid('project'),
title: string().min(1),
priority: number(),
done: boolean(),
deletedAt: number().optional()
})
})Backend
import { orgCascade } from 'noboil/convex/server'
export const {
addEditor,
create,
editors,
list,
read,
removeEditor,
rm,
setEditors,
update
} = orgCrud('project', orgScoped.project, {
acl: true,
cascade: orgCascade(orgScoped.task, {
foreignKey: 'projectId',
table: 'task'
})
})
export const {
create: createTask,
list: listTasks,
rm: rmTask,
update: updateTask,
restore
} = orgCrud('task', orgScoped.task, {
aclFrom: { field: 'projectId', table: 'project' },
softDelete: true
})Frontend
import { useOrgQuery, useOrgMutation } from 'noboil/convex/react'
import { EditorsSection, PermissionGuard } from 'noboil/convex/components'
const ProjectPage = ({ projectId }: { projectId: Id<'project'> }) => {
const project = useOrgQuery(api.project.read, { id: projectId })
const tasks = useOrgQuery(api.task.list, {
paginationOpts: { cursor: null, numItems: 50 }
})
const remove = useOrgMutation(api.project.rm)
return (
<>
<PermissionGuard doc={project} fallback={<p>View only</p>}>
<button onClick={() => remove({ id: projectId })}>Delete</button>
</PermissionGuard>
<EditorsSection docId={projectId} api={api.project} />
<ul>
{tasks?.page.map(t => (
<li key={t._id}>{t.title}</li>
))}
</ul>
</>
)
}Schema
s.ts:
import { schema } from 'noboil/spacetimedb/schema'
import { object, string } from 'zod/v4'
const s = schema({
orgScoped: {
project: object({ description: string().optional(), name: string().min(1) })
}
})
export { s }index.ts:
import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table }) => ({
project: table(s.project)
}) })Client
'use client'
import { useTable, useReducer } from 'spacetimedb/react'
import { useList, useOrg, useOrgMutation } from 'noboil/spacetimedb/react'
import { tables, reducers } from '@/generated/module_bindings'
const ProjectList = () => {
const { org } = useOrg()
const [allProjects, isReady] = useTable(tables.project)
const { data: projects } = useList(allProjects, isReady, {
where: { orgId: org.id },
sort: { field: 'updatedAt', direction: 'desc' },
})
const createProject = useOrgMutation(useReducer(reducers.create_project))
return (
<div>
<button onClick={() => createProject({ name: 'New project' })}>
New project
</button>
<ul>
{projects.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
)
}useOrgMutation injects orgId automatically on every call.
Cache with External API
Features: TTL · external API · load/refresh · rate limiting
Schema
import { makeBase } from 'noboil/convex/schema'
import { number, object, string } from 'zod/v4'
const base = makeBase({
movie: object({
tmdb_id: number(),
title: string(),
overview: string(),
poster_path: string().nullable(),
vote_average: number()
})
})Backend
export const { all, get, load, refresh, invalidate, purge } = cacheCrud({
table: 'movie',
schema: base.movie,
key: 'tmdb_id',
ttl: 86400,
fetcher: async (_, tmdbId) => {
const res = await fetch(
`https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${process.env.TMDB_KEY}`
)
const { id, title, overview, poster_path, vote_average } = await res.json()
return { tmdb_id: id, title, overview, poster_path, vote_average }
},
rateLimit: { max: 30, window: 60_000 }
})Frontend
import { useQuery, useMutation } from 'convex/react'
const MoviePage = ({ tmdbId }: { tmdbId: number }) => {
const movie = useQuery(api.movie.get, { key: tmdbId })
const loadMovie = useMutation(api.movie.load)
const refreshMovie = useMutation(api.movie.refresh)
if (movie === undefined) {
loadMovie({ key: tmdbId })
return <p>Loading...</p>
}
return (
<>
<h1>{movie.title}</h1>
<p>{movie.overview}</p>
<p>Rating: {movie.vote_average}/10</p>
<button onClick={() => refreshMovie({ key: tmdbId })}>Refresh</button>
</>
)
}Schema
s.ts:
import { schema } from 'noboil/spacetimedb/schema'
import { number, object, string } from 'zod/v4'
const s = schema({
base: {
movie: object({
overview: string(),
title: string(),
tmdbId: number(),
voteAverage: number()
})
}
})
export { s }index.ts:
import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table }) => ({
movie: table(s.movie, { key: 'tmdbId' })
}) })Next.js API route for cache population
Since ctx.http.fetch() panics in local Docker, use a Next.js API route to fetch from the external API and populate the cache. SpacetimeDB's SQL API does not support parameterized queries, but since tmdbId is validated as a number, injection is prevented at the validation layer.
import { NextResponse } from 'next/server'
const STDB_URI = process.env.SPACETIMEDB_URI ?? 'http://localhost:4200'
const MODULE = process.env.MODULE_NAME ?? 'my-app'
const TMDB_API_KEY = process.env.TMDB_API_KEY
export const GET = async (
_req: Request,
{ params }: { params: { tmdbId: string } }
) => {
const tmdbId = Number(params.tmdbId)
const cacheRes = await fetch(`${STDB_URI}/v1/database/${MODULE}/sql`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: `SELECT * FROM movie WHERE tmdb_id = ${tmdbId} AND invalidated_at IS NULL LIMIT 1`
})
const [cacheResult] = (await cacheRes.json()) as [{ rows: unknown[][] }]
if (cacheResult.rows.length > 0) {
return NextResponse.json({ source: 'cache', data: cacheResult.rows[0] })
}
const tmdbRes = await fetch(
`https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${TMDB_API_KEY}`
)
const movie = (await tmdbRes.json()) as {
id: number
title: string
overview: string
vote_average: number
}
return NextResponse.json({ source: 'api', data: movie })
}Client
'use client'
import { useTable } from 'spacetimedb/react'
import { useList } from 'noboil/spacetimedb/react'
import { tables } from '@/generated/module_bindings'
const MovieList = () => {
const [movies, isReady] = useTable(tables.movie)
const { data } = useList(movies, isReady, {
where: { invalidatedAt: undefined },
sort: { field: 'voteAverage', direction: 'desc' },
})
return (
<ul>
{data.map(movie => (
<li key={movie.id}>
{movie.title} ({movie.voteAverage.toFixed(1)})
</li>
))}
</ul>
)
}Soft Delete with Restore
Soft delete is available on any orgCrud table via softDelete: true. The restore mutation is generated automatically.
export const {
create: createTask,
list: listTasks,
rm: rmTask,
update: updateTask,
restore
} = orgCrud('task', orgScoped.task, {
softDelete: true
})Filter active vs deleted rows using where:
import { useList } from 'noboil/convex/react'
const { items: activeTasks } = useList(api.task.listTasks, {
where: { deletedAt: undefined }
})Schema
s.ts:
import { schema } from 'noboil/spacetimedb/schema'
import { object, string } from 'zod/v4'
const s = schema({
orgScoped: {
wiki: object({ content: string().optional(), title: string().min(1) })
}
})
export { s }index.ts:
import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table }) => ({
wiki: table(s.wiki, { softDelete: true })
}) })Client
Filter out deleted rows:
const { data: activeWikis } = useList(wikis, isReady, {
where: { deletedAt: undefined }
})
const { data: deletedWikis } = useList(wikis, isReady, {
where: { deletedAt: { $gt: 0 } }
})Restore by setting deletedAt back to null:
const updateWiki = useReducer(reducers.update_wiki)
const restore = async (id: number) => {
await updateWiki({ id, deletedAt: null })
}Real-Time Presence Tracking
Features: presence · cursor tracking · online status · typing indicators
Schema
import { presenceTable } from 'noboil/convex/server'
export default defineSchema({
...presenceTable()
})Backend
import { makePresence } from 'noboil/convex/server'
export const { heartbeat, list } = makePresence({
mutation,
query
})Frontend
import { usePresence } from 'noboil/convex/react'
const CollaborativeEditor = ({ docId }: { docId: string }) => {
const refs = {
heartbeat: api.presence.heartbeat,
leave: api.presence.leave,
list: api.presence.list,
}
const { users, updatePresence } = usePresence(refs, docId, {
data: { cursor: { x: 0, y: 0 }, status: 'viewing' as const }
})
const handleMouseMove = (e: React.MouseEvent) => {
updatePresence({
cursor: { x: e.clientX, y: e.clientY },
status: 'editing'
})
}
return (
<div onMouseMove={handleMouseMove}>
{users.map(p => (
<div
key={p.userId}
className="absolute size-4 rounded-full bg-blue-500"
style={{ left: p.data.cursor.x, top: p.data.cursor.y }}
/>
))}
</div>
)
}Schema
index.ts:
import { noboil, makePresence } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table, t }) => {
const presence = makePresence({
dataField: t.string(),
roomIdField: t.string()
})
return {
chat: table(s.chat, { index: ['isPublic'] }),
message: table(s.message),
...presence.tables
}
} })Frontend
'use client'
import { useTable, useReducer } from 'spacetimedb/react'
import { usePresence } from 'noboil/spacetimedb/react'
import { tables, reducers } from '@/generated/module_bindings'
const CollaborativeRoom = ({ roomId }: { roomId: string }) => {
const [presenceRows] = useTable(tables.presence)
const heartbeat = useReducer(reducers.presence_heartbeat_presence)
const { users, updatePresence } = usePresence(
presenceRows,
async ({ data } = {}) => heartbeat({ roomId, data: JSON.stringify(data ?? {}) }),
)
const handleMouseMove = (e: React.MouseEvent) => {
updatePresence({ cursor: { x: e.clientX, y: e.clientY } })
}
return (
<div onMouseMove={handleMouseMove}>
<span>{users.length} online</span>
</div>
)
}Real-Time Chat
Features: rooms · messages · live presence
Both demo apps ship a working chat implementation — see web/cvx/chat and web/stdb/chat. The SpacetimeDB version is shown below; the Convex version uses crud('chat', owned.chat) + childCrud('message', children.message) + usePresence.
Schema
s.ts:
import { child, schema } from 'noboil/spacetimedb/schema'
import { boolean, object, string } from 'zod/v4'
const s = schema({
owned: {
chat: object({ isPublic: boolean(), title: string().min(1) })
},
children: {
message: child('chat', object({ content: string() }))
}
})
export { s }index.ts:
import { noboil, makePresence } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table, t }) => {
const presence = makePresence({
dataField: t.string(),
roomIdField: t.string()
})
return {
chat: table(s.chat, { index: ['isPublic'] }),
message: table(s.message),
...presence.tables
}
} })Chat component
'use client'
import { useTable, useReducer } from 'spacetimedb/react'
import { useList, usePresence } from 'noboil/spacetimedb/react'
import { tables, reducers } from '@/generated/module_bindings'
const ChatRoom = ({ chatId }: { chatId: number }) => {
const [allMessages, messagesReady] = useTable(
tables.message.where(r => r.chatId.eq(chatId))
)
const { data: messages } = useList(allMessages, messagesReady, {
sort: { field: 'id', direction: 'asc' },
})
const [presenceRows] = useTable(tables.presence)
const heartbeat = useReducer(reducers.presence_heartbeat_presence)
const { users, updatePresence } = usePresence(
presenceRows,
async ({ data } = {}) => heartbeat({ roomId: String(chatId), data: JSON.stringify(data ?? {}) }),
)
const createMessage = useReducer(reducers.create_message)
const handleSend = async (content: string) => {
await createMessage({ chatId, content })
}
const handleMouseMove = (e: React.MouseEvent) => {
updatePresence({ cursor: { x: e.clientX, y: e.clientY } })
}
return (
<div onMouseMove={handleMouseMove}>
<div>{users.length} online</div>
<ul>
{messages.map(msg => (
<li key={msg.id}>{msg.content}</li>
))}
</ul>
<MessageInput onSend={handleSend} />
</div>
)
}File Upload
Use file() in your schema — file storage works automatically on both backends. Convex uses built-in storage; SpacetimeDB stores files inline as byte arrays (up to ~100MB).
const owned = makeOwned({
post: object({
title: string().min(1),
coverImage: file().nullable().optional()
})
})<Form form={form} render={({ Text, File, Submit }) => (
<>
<Text name="title" />
<File name="coverImage" accept="image/*" />
<Submit>Save</Submit>
</>
)} />The <File> component handles upload, preview, and storage registration — it just works.
Displaying uploaded files after reload
Blob URLs created during upload are session-scoped — they die on page reload. Use resolveFileUrl to resolve file references from the subscribed file table:
import { resolveFileUrl } from 'noboil/spacetimedb/react'
import { useTable } from 'spacetimedb/react'
const Avatar = ({ fileRef }: { fileRef: string | null }) => {
const [files] = useTable(tables.file)
const src = fileRef ? resolveFileUrl(files, fileRef) : null
return src ? <img src={src} /> : null
}resolveFileUrl matches the reference against file rows by filename or id, converts the stored bytes to a blob URL, and caches the result. Works with any value stored by useUpload.
Multi-Step Onboarding Form
Features: defineSteps · per-step validation · typed merged data · step navigation
defineSteps is exported from both noboil/convex/components and noboil/spacetimedb/components with an identical API — only the import path and the mutation wiring inside onSubmit differ. The Convex version is shown below.
Schema
import { file } from 'noboil/convex/schema'
import { object, string, enum as zenum } from 'zod/v4'
const profileStep = object({
displayName: string().min(1),
avatar: file().nullable().optional()
})
const orgStep = object({
name: string().min(2),
slug: string()
.min(2)
.regex(/^[a-z0-9-]+$/)
})
const preferencesStep = object({
theme: zenum(['light', 'dark', 'system']),
language: zenum(['en', 'es', 'fr', 'de'])
})Backend
export const { upsert } = singletonCrud('profile', singleton.profile)
export const { create: createOrg } = orgFns({
mutation,
query,
internalMutation,
internalQuery
})Frontend
import { defineSteps } from 'noboil/convex/components'
import { useMutation } from 'convex/react'
const { StepForm, useStepper } = defineSteps(
{ id: 'profile', label: 'Profile', schema: profileStep },
{ id: 'org', label: 'Organization', schema: orgStep },
{ id: 'preferences', label: 'Preferences', schema: preferencesStep }
)
const Onboarding = () => {
const upsert = useMutation(api.profile.upsert)
const createOrg = useMutation(api.org.create)
const stepper = useStepper({
onSubmit: async d => {
await upsert({
displayName: d.profile.displayName,
avatar: d.profile.avatar
})
await createOrg({ name: d.org.name, slug: d.org.slug })
},
onSuccess: () => router.push('/dashboard')
})
return (
<StepForm stepper={stepper} submitLabel="Complete">
<StepForm.Step
id="profile"
render={({ Text, File }) => (
<>
<Text name="displayName" />
<File name="avatar" accept="image/*" />
</>
)}
/>
<StepForm.Step
id="org"
render={({ Text }) => (
<>
<Text name="name" />
<Text name="slug" />
</>
)}
/>
<StepForm.Step
id="preferences"
render={({ Choose }) => (
<>
<Choose name="theme" />
<Choose name="language" />
</>
)}
/>
</StepForm>
)
}Singleton Profile with File Upload
Features: singletonCrud · 1:1 per-user · file upload · upsert
singletonCrud from a singleton: { profile } schema slot. The Convex flow is shown below; the SpacetimeDB version uses the same schema with inline byte storage.
Schema
import { file, makeSingleton } from 'noboil/convex/schema'
import { object, string, enum as zenum } from 'zod/v4'
const singleton = makeSingleton({
profile: object({
displayName: string().min(1),
bio: string().optional(),
avatar: file().nullable().optional(),
theme: zenum(['light', 'dark', 'system'])
})
})Backend
export const { get, upsert } = singletonCrud('profile', singleton.profile)Frontend
import { useQuery } from 'convex/react'
import { Form, useForm } from 'noboil/convex/components'
import { pickValues } from 'noboil/convex/zod'
const ProfilePage = () => {
const profile = useQuery(api.profile.get)
const upsert = useMutation(api.profile.upsert)
const form = useForm({
schema: singleton.profile,
values: profile ? pickValues(singleton.profile, profile) : undefined,
onSubmit: async d => {
await upsert(d)
return d
}
})
return (
<Form
form={form}
render={({ Text, File, Choose, Submit }) => (
<>
<Text name="displayName" />
<Text name="bio" multiline />
<File name="avatar" accept="image/*" />
<Choose name="theme" />
<Submit>Save</Submit>
</>
)}
/>
)
}Custom Queries Alongside CRUD (Convex)
The pq / q / m builders are part of the Convex setup flow. SpacetimeDB users write custom reducers directly in their module file alongside the noboil() call — see Custom Queries for the SpacetimeDB equivalents.
Features: pq/q/m escape hatches · typed args · coexistence with CRUD
Backend
import { z } from 'zod/v4'
export const { create, list, read, rm, update } = crud('blog', owned.blog, {
rateLimit: { max: 10, window: 60_000 }
}),
stats = pq({
args: { category: z.string().optional() },
handler: async (c, { category }) => {
const docs = await c.db.query('blog').collect()
let total = 0
let published = 0
for (const d of docs) {
if (category && d.category !== category) continue
total++
if (d.published) published++
}
return { total, published, draft: total - published }
}
}),
bySlug = pq({
args: { slug: z.string() },
handler: async (c, { slug }) => {
const doc = await c.db
.query('blog')
.withIndex('by_slug', q => q.eq('slug', slug))
.unique()
return doc ? (await c.withAuthor([doc]))[0] : null
}
}),
archive = m({
args: { id: z.string() },
handler: async (c, { id }) => c.patch(id, { published: false })
})Frontend
import { useQuery } from 'convex/react'
import { useList } from 'noboil/convex/react'
const Dashboard = () => {
const stats = useQuery(api.blog.stats, { category: 'tech' })
const { items } = useList(api.blog.list)
return (
<>
<p>
{stats?.published} published / {stats?.draft} drafts
</p>
<ul>
{items.map(b => (
<li key={b._id}>{b.title}</li>
))}
</ul>
</>
)
}Bulk Operations with Progress
Features: useBulkMutate · toast feedback · progress tracking · error collection
useBulkMutate is available from both noboil/convex/react and noboil/spacetimedb/react. The Convex version is shown below; on SpacetimeDB, pass useReducer(reducers.rm_blog) instead of a Convex mutation.
Uses rm from any crud() call — pass { ids: [...] } for bulk deletion. No extra backend code needed.
Frontend
import { useMutation } from 'convex/react'
import { useBulkMutate } from 'noboil/convex/react'
const BulkActions = ({ selectedIds }: { selectedIds: string[] }) => {
const rm = useMutation(api.blog.rm)
const { isPending, progress, run } = useBulkMutate(
(id: string) => rm({ id }),
{
onProgress: p => console.log(`${p.succeeded}/${p.total}`),
toast: {
error: 'Some items failed to delete',
loading: p => `Deleting ${p.succeeded}/${p.total}...`,
success: n => `Deleted ${n} items`
}
}
)
return (
<button
disabled={isPending}
onClick={() => {
run(selectedIds)
}}
>
{progress
? `${progress.succeeded}/${progress.total}`
: `Delete ${selectedIds.length}`}
</button>
)
}useBulkMutate fires all mutations concurrently, tracks per-item success/failure, and returns { errors, results, settled } when done. Use onError: false to silence the default error toast. Use onSettled for cleanup that runs regardless of outcome.
Ownership Flags with useOwnRows
Features: useOwnRows · per-row ownership · conditional UI
useOwnRows is exported from both noboil/convex/react and noboil/spacetimedb/react. See the Data Fetching page for the SpacetimeDB variant which compares userId against the connected Identity. The Convex version is shown below.
Frontend
import { useQuery } from 'convex/react'
import { useList, useOwnRows } from 'noboil/convex/react'
const BlogList = () => {
const me = useQuery(api.users.viewer)
const { items } = useList(api.blog.pub.list)
const blogs = useOwnRows(items, me ? b => b.userId === me._id : null)
return (
<ul>
{blogs.map(b => (
<li key={b._id}>
{b.title}
{b.own && <span>yours</span>}
</li>
))}
</ul>
)
}useOwnRows annotates each row with own: boolean using a memoized predicate. Pass null when the user is unauthenticated and all rows get own: false. Use the own flag to conditionally render edit/delete buttons without extra queries.
Post-Mutation Workflows
Features: onSuccess · onSettled · redirect · reset form state · toast
Use onSuccess and onSettled callbacks on any Convex mutation:
import { useMutation } from 'convex/react'
const CreatePost = () => {
const router = useRouter()
const create = useMutation(api.post.create)
const handleSubmit = async (data: PostCreate) => {
await create(data)
router.push('/posts')
}
return <form onSubmit={...}>{...}</form>
}'use client'
import { useMut } from 'noboil/spacetimedb/react'
import { useRouter } from 'next/navigation'
import { reducers } from '@/generated/module_bindings'
const CreatePostForm = () => {
const router = useRouter()
const save = useMut(reducers.create_post, {
onSuccess: (_result, args) => {
router.push('/posts')
},
onSettled: (_args, error) => {
setSubmitting(false)
if (error) console.error('Create failed:', error)
}
})
return (
<form onSubmit={async e => {
e.preventDefault()
const form = new FormData(e.currentTarget)
await save({
title: form.get('title') as string,
content: form.get('content') as string,
published: false
})
}}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create</button>
</form>
)
}onSettled fires whether the mutation succeeds or fails. Use it for cleanup that must always happen (clearing spinners, resetting flags). Use onSuccess for actions that only make sense on success (redirects, toasts).
Typing Components with InferRow, InferCreate, InferUpdate
Available for SpacetimeDB only. Convex users derive types directly from Zod schemas using
z.infer.
Derive prop types from your schema brands instead of duplicating type definitions.
import type { InferRow, InferCreate, InferUpdate } from 'noboil/spacetimedb/server'
import { schema } from 'noboil/spacetimedb/schema'
import { boolean, object, string } from 'zod/v4'
const s = schema({
owned: {
post: object({
title: string(),
content: string(),
published: boolean()
})
}
})
type PostRow = InferRow<typeof s.post>
type PostCreate = InferCreate<typeof s.post>
type PostUpdate = InferUpdate<typeof s.post>
const PostCard = ({ post }: { post: PostRow }) => (
<div>
<h2>{post.title}</h2>
<p>{post.content}</p>
<span>{post.published ? 'Published' : 'Draft'}</span>
</div>
)
const CreatePostForm = ({ onSubmit }: { onSubmit: (data: PostCreate) => void }) => {
return null
}
const EditPostForm = ({ post, onSubmit }: { post: PostRow; onSubmit: (data: PostUpdate) => void }) => {
return null
}InferRow includes the database-added fields (id, updatedAt, userId for owned schemas). InferCreate is the raw field shape you pass to the create reducer. InferUpdate makes all fields optional.
Phantom Type Inference
Available for SpacetimeDB only. Branded schemas expose
$inferRow,$inferCreate, and$inferUpdateas readable properties. No import needed.
import { schema } from 'noboil/spacetimedb/schema'
import { boolean, object, string } from 'zod/v4'
const s = schema({
owned: {
post: object({
title: string(),
content: string(),
published: boolean()
})
}
})
type PostRow = typeof s.post.$inferRow
type PostCreate = typeof s.post.$inferCreate
type PostUpdate = typeof s.post.$inferUpdatePostRow includes the database-added fields. This is equivalent to InferRow<typeof s.post> but skips the import.
The ~types accessor groups all three:
type PostTypes = (typeof s.post)['~types']
type PostRow = PostTypes['row']
type PostCreate = PostTypes['create']
type PostUpdate = PostTypes['update']Use these in component props to stay in sync with the schema:
const PostCard = ({ post }: { post: typeof s.post.$inferRow }) => (
<div>
<h2>{post.title}</h2>
<p>{post.content}</p>
</div>
)Global Error Type with Register
Available for SpacetimeDB only. Use declaration merging to set a project-wide default error type. All
noboil/spacetimedbhooks and utilities will use your type instead of the defaultError.
declare module 'noboil/spacetimedb/server' {
interface Register {
defaultError: AppError
}
}
interface AppError {
code: string
message: string
requestId?: string
}After augmenting Register, RegisteredDefaultError resolves to AppError:
import type { RegisteredDefaultError } from 'noboil/spacetimedb/server'
const handleError = (error: RegisteredDefaultError) => {
console.error(`[${error.requestId}] ${error.code}: ${error.message}`)
}Create and Update Forms with schemaVariants
Available for SpacetimeDB only. Convex users can use Zod's
.partial()directly orpickValuesfromnoboil/convex/zod.
Define one base schema and derive both create and update variants from it.
import { schemaVariants } from 'noboil/spacetimedb/zod'
import { boolean, object, string } from 'zod/v4'
const postSchema = object({
title: string().min(1, 'Title is required'),
content: string().min(10, 'Content must be at least 10 characters'),
published: boolean()
})
const { create: createSchema, update: updateSchema } = schemaVariants(postSchema, ['title'])
const CreatePost = () => {
const createPost = useReducer(reducers.create_post)
const form = useForm({
schema: createSchema,
onSubmit: async ({ value }) => {
await createPost(value)
}
})
return (
<Form form={form}>
<fields.Text name="title" required />
<fields.Text name="content" multiline required />
<fields.Toggle name="published" trueLabel="Published" />
<fields.Submit>Create</fields.Submit>
</Form>
)
}
const EditPost = ({ post }: { post: PostRow }) => {
const updatePost = useReducer(reducers.update_post)
const form = useForm({
schema: updateSchema,
defaultValues: { title: post.title, content: post.content, published: post.published },
onSubmit: async ({ value }) => {
await updatePost({ id: post.id, ...value })
}
})
return (
<Form form={form}>
<fields.Text name="title" required />
<fields.Text name="content" multiline />
<fields.Toggle name="published" trueLabel="Published" />
<fields.Submit>Save</fields.Submit>
</Form>
)
}Typed Form Validation Errors with getFieldErrors
Available for SpacetimeDB only. Surface server-side field validation errors back into your form UI.
'use client'
import { getFieldErrors } from 'noboil/spacetimedb/server'
import { useMut } from 'noboil/spacetimedb/react'
import { useState } from 'react'
import { reducers } from '@/generated/module_bindings'
import { z } from 'zod/v4'
const postSchema = z.object({
title: z.string().min(1),
content: z.string().min(10),
published: z.boolean()
})
const CreatePost = () => {
const [fieldErrors, setFieldErrors] = useState<Partial<{ title: string; content: string }>>({})
const save = useMut(reducers.create_post, {
onError: error => {
const errors = getFieldErrors<typeof postSchema>(error)
if (errors) {
setFieldErrors(errors)
return
}
}
})
return (
<form onSubmit={async e => {
e.preventDefault()
setFieldErrors({})
const form = new FormData(e.currentTarget)
await save({
title: form.get('title') as string,
content: form.get('content') as string,
published: false
})
}}>
<div>
<input name="title" />
{fieldErrors.title && <p className="text-red-500">{fieldErrors.title}</p>}
</div>
<div>
<textarea name="content" />
{fieldErrors.content && <p className="text-red-500">{fieldErrors.content}</p>}
</div>
<button type="submit">Create</button>
</form>
)
}getFieldErrors returns undefined when the error has no field-level data, so you can safely fall through to a generic error handler.
Reducing Mutation Boilerplate with useMutation
Available for SpacetimeDB only.
useMutationcombinesuseReducer+useMutateinto one call.
Before:
import { useMutate } from 'noboil/spacetimedb/react'
import { useReducer } from 'spacetimedb/react'
import { reducers } from '@/generated/module_bindings'
const raw = useReducer(reducers.update_blog)
const save = useMutate(raw, { onSuccess: () => toast.success('Saved') })With useMutation:
import { useMutation } from 'noboil/spacetimedb/react'
import { useReducer } from 'spacetimedb/react'
import { reducers } from '@/generated/module_bindings'
const save = useMutation(useReducer, reducers.update_blog, {
onSuccess: () => toast.success('Saved')
})With useMut (simplest):
import { useMut } from 'noboil/spacetimedb/react'
import { reducers } from '@/generated/module_bindings'
const save = useMut(reducers.update_blog, {
onSuccess: () => toast.success('Saved')
})All MutateOptions work the same way: onSuccess, onSettled, onError, retry, optimistic, etc.
Toast Shorthand with useMutation
Available for SpacetimeDB only. The
toastoption replaces manualonSuccess/onErrorcallbacks when all you need is a message.
Before:
import { useMutation } from 'noboil/spacetimedb/react'
import { useReducer } from 'spacetimedb/react'
import { reducers } from '@/generated/module_bindings'
const save = useMutation(useReducer, reducers.update_blog, {
onSuccess: () => toast.success('Saved'),
onError: () => toast.error('Save failed')
})After, with dynamic messages:
const save = useMutation(useReducer, reducers.update_blog, {
toast: {
success: (result, args) => `"${args.title}" saved`,
error: err =>
`Save failed: ${err instanceof Error ? err.message : 'unknown'}`
}
})fieldErrors defaults to true. If the server returns field validation errors, the first one is toasted before the generic error message. Set fieldErrors: false to skip that behavior.
onSuccess and toast.success compose; both run when provided.
useFormMutation also accepts toast: { success?, error? } with the same composition rules:
const form = useFormMutation({
schema: blogSchema,
mutate: useReducer(reducers.createBlog),
toast: { success: 'Created' },
onSuccess: () => router.push('/posts')
})Field Validation Error Toasts with toastFieldError
Available for SpacetimeDB only. Use
toastFieldErrorto surface the first field validation error as a toast, then fall back to a generic message for non-field errors.
'use client'
import { toastFieldError } from 'noboil/spacetimedb/react'
import { useMutation } from 'noboil/spacetimedb/react'
import { useReducer } from 'spacetimedb/react'
import { reducers } from '@/generated/module_bindings'
const BlogEditor = () => {
const save = useMutation(useReducer, reducers.update_blog, {
onError: error => {
if (!toastFieldError(error, toast.error)) {
toast.error('Something went wrong')
}
}
})
const handleSubmit = async (data: { id: number; title: string; content: string }) => {
await save(data)
}
return <form onSubmit={...}>{...}</form>
}toastFieldError returns true when it toasted a field error, so the if block only runs for other error types. Pair it with getFieldErrors when you need to display errors inline in the form rather than as toasts.
Error Discrimination with SenderError._tag
Available for SpacetimeDB only.
SenderErrorcarries_tag: 'SenderError'as a const property. Use it to narrow errors in catch blocks when you need to distinguishnoboil/spacetimedbreducer errors from other thrown values.
import { extractErrorData } from 'noboil/spacetimedb/server'
try {
await save(data)
} catch (e) {
if (e instanceof Error && '_tag' in e && e._tag === 'SenderError') {
const data = extractErrorData(e)
if (data?.code === 'CONFLICT') {
showConflictDialog(data)
return
}
}
throw e
}For most cases, handleError or matchError is simpler. They parse the error internally without the _tag check. Use _tag when you need to re-throw non-noboil/spacetimedb errors or integrate with an external error boundary that inspects error shape.
AutoForm — Zero-Layout Forms
AutoForm auto-renders all fields from your schema. No manual field layout needed.
import { AutoForm, useFormMutation } from 'noboil/convex/components'
const CreateBlog = () => {
const form = useFormMutation({
mutation: api.blog.create,
schema: s.blog
})
return <AutoForm form={form} submitLabel='Create Blog' />
}All field types are auto-detected from the schema: strings render as Text, booleans as Toggle, files as File, arrays as Arr, etc. Use exclude to hide specific fields:
<AutoForm form={form} exclude={['published']} submitLabel='Save Draft' />For full control over field layout, use Form with the render prop instead:
<Form form={form} render={f => (
<>
<f.Text name='title' required />
<f.Text name='content' multiline />
<f.Submit>Create</f.Submit>
</>
)} />File Constraints in Schema
Specify accept and maxSize directly in the schema — they auto-apply to form fields:
import { file, files } from 'noboil/convex/schema'
const s = schema({
owned: {
post: object({
title: string().min(1),
cover: file({ accept: 'image/*', maxSize: 5 * 1024 * 1024 }).nullable().optional(),
attachments: files({ accept: 'image/*,application/pdf', maxSize: 10 * 1024 * 1024 }).optional()
})
}
})No need to pass accept or maxSize props to <f.File> or <f.Files> — they read from schema meta automatically. Props still override if needed.
Auto Conflict Detection
Pass doc to useFormMutation to auto-inject expectedUpdatedAt:
const form = useFormMutation({
doc: blog,
mutation: api.blog.update,
schema: s.blog,
transform: d => ({ ...d, id: blog._id }),
values: pickValues(s.blog, blog)
})If another user edits the same document, the server returns CONFLICT and the ConflictDialog lets the user choose: cancel, reload, or overwrite.
Custom Error Codes
ErrorCode accepts any string while keeping autocomplete for built-in codes:
import { err } from 'noboil/convex/server'
err('EMAIL_TAKEN', { message: 'This email is already registered' })Handle custom codes with matchError:
import { matchError } from 'noboil/convex/server'
matchError(error, {
EMAIL_TAKEN: data => toast.error(data.message),
VALIDATION_FAILED: data => showFieldErrors(data.fieldErrors),
default: () => toast.error('Something went wrong')
})Unified CRUD with useCrud + createApi
createApi builds typed refs from your backend exports. useCrud consumes them — same code on both backends:
// Convex — create api once
import { createApi } from 'noboil/convex/react'
import { api as raw } from '@a/be-convex'
export const api = createApi({ blog: raw.blog, chat: raw.chat })// SpacetimeDB — create api once
import { createApi } from 'noboil/spacetimedb/react'
import { reducers, tables } from '@a/be-spacetimedb/spacetimedb'
export const api = createApi(tables, reducers)Then in any component — identical on both backends:
import { useCrud } from 'noboil/convex/react' // or noboil/spacetimedb/react
const BlogList = () => {
const { data, create, update, rm, hasMore, loadMore, isLoading } = useCrud(api.blog)
return <>{data.map(b => <div key={b._id}>{b.title}</div>)}</>
}useCrud(api.blog) returns { data, create, update, rm, hasMore, isLoading, loadMore } on both backends. The only backend-specific code is the one-time createApi call.
You can also pass refs directly without createApi:
// Convex — inline refs
useCrud({
create: api.blog.create,
list: api.blog.pub.list,
rm: api.blog.rm,
update: api.blog.update
})
// SpacetimeDB — inline refs
useCrud({
create: reducers.createBlog,
rm: reducers.rmBlog,
table: tables.blog,
update: reducers.updateBlog
})Both return the same { data, create, update, rm, hasMore, isLoading, loadMore }. The only difference is refs: Convex passes api.* functions, SpacetimeDB passes reducers.* + tables.*.
Poll: kv banner + log votes + quota anti-spam
A working poll page that composes three new factories: a kv siteConfig.banner row read by every visitor, a log vote table for ballots, and a quota pollVote limit gating writes per ${userId}:${pollId}. See the full source in web/cvx/poll.
// backend/convex/s.ts
const s = schema({
owned: { poll: object({ options: array(string()).min(2).max(10), question: string() }) },
log: { vote: { parent: 'poll', schema: object({ optionIdx: number(), voter: string() }) } },
kv: { siteConfig: { keys: ['banner'] as const, schema: object({ active: boolean(), message: string() }), writeRole: true } },
quota: { pollVote: { durationMs: 60_000, limit: 30 } }
})// backend/convex/lazy.ts
tables: ({ table }) => ({
poll: table(s.poll),
vote: table(s.vote, { softDelete: true }),
siteConfig: table(s.siteConfig, { softDelete: true }),
pollVoteQuota: table(s.pollVote)
})// web/cvx/poll/src/app/page.tsx
const VoteView = ({ pollId, options }: { pollId: string; options: string[] }) => {
const log = useLog(api.vote, { parent: pollId })
const quota = useQuota(api.pollVoteQuota, pollId)
const counts = options.map((_, i) => log.data.filter(v => v.optionIdx === i).length)
const vote = async (i: number) => {
const r = await quota.consume()
if (!r.allowed) return toast.error(`retry in ${r.retryAfter}ms`)
await log.append({ payload: { optionIdx: i, voter: 'me' } })
}
return options.map((opt, i) => (
<Button key={opt} disabled={!quota.state?.allowed} onClick={() => vote(i)}>
{opt} ({counts[i]})
</Button>
))
}
const BannerDisplay = () => {
const banner = useKv(api.siteConfig, 'banner')
return banner.data?.active ? <Alert>{banner.data.message}</Alert> : null
}This composes:
logfor append-only votes with atomic per-poll seq + soft-delete + restorekvfor a publicly-readable site banner with role-gated writesquotaas the gate before eachlog.append(anti-ballot-stuffing)
End-to-end coverage: 82 Playwright tests (web/cvx/poll/e2e/*) exercise this exact flow including bulk append, purge/restore, banner restore, quota exhaustion, and the per-poll detail/edit routes.
JSDoc @example library
Every @example JSDoc block harvested from lib/noboil/src/. These are tested-by-living-source examples — the surrounding code must compile.
32 @example blocks harvested from JSDoc across lib/noboil/src/.
asDb — lib/noboil/src/convex/server/setup.ts
const { crud, orgCrud, pq, q, m } = setup({
query, mutation, action, internalQuery, internalMutation, getAuthUserId
})
// Then generate endpoints:
export const { create, update, rm, pub: { list, read } } = crud('blog', owned.blog)AuditExports — lib/noboil/src/convex/server/audit.ts
// convex/audit.ts
export const { append, recent, listByActor, listByTrace, pruneStale } = makeAudit({
builders, table: 'audit', options: { ttlMs: 30 * 24 * 60 * 60 * 1000 }
})BudgetExports — lib/noboil/src/convex/server/budget.ts
// convex/llmBudget.ts
export const { reserve, settle, check, add, pruneStale, auditInvariants } = makeBudget({
builders, table: 'llmBudget', cap: 10_000, inflightMax: 50, periodMs: 24 * 60 * 60 * 1000
})CacheOptions — lib/noboil/src/convex/server/types.ts
makeCacheCrud({ builders, schema: schemas.movie, table: 'movie', options: {
key: 'tmdbId',
fetcher: async (_, id) => fetchMovieFromTmdb(Number(id)),
ttl: 24 * 60 * 60 * 1000,
staleWhileRevalidate: true
} })CrudConfig — lib/noboil/src/spacetimedb/server/types/crud.ts
makeCrud(spacetimedb, {
tableName: 'todo',
fields: { done: t.boolean(), title: t.string() },
idField: t.u32(),
pk: tbl => tbl.id,
table: db => db.todo,
options: { rateLimit: { max: 30, window: 60_000 }, softDelete: true }
})CrudHooks — lib/noboil/src/convex/server/types.ts
makeCrud({ schema, table: 'todo', options: { hooks: {
beforeCreate: (ctx, { data }) => ({ ...data, ownerId: ctx.userId }),
afterDelete: async (ctx, { id }) => { await ctx.db.delete(id) }
} } })
Typechecked usage: `lib/noboil/examples/convex/make-crud.example.ts`.CrudOptions — lib/noboil/src/convex/server/types.ts
makeCrud({ schema: schemas.todo, table: 'todo', options: {
pub: 'isPublished',
softDelete: true,
rateLimit: { max: 30, window: 60_000 },
search: 'title'
} })CrudResult — lib/noboil/src/convex/server/types.ts
// convex/todo.ts
import { makeCrud } from 'noboil/convex/server'
import { schemas } from './schema'
export const { auth, pub, authIndexed, create, update, rm } = makeCrud({
builders, schema: schemas.todo, table: 'todo', options: { pub: true }
})ErrorData — lib/noboil/src/shared/server/helpers.ts
interface ComparisonOp<V> {
$between?: [V, V]
$gt?: V
$gte?: V
$lt?: V
$lte?: V
}
/** Structured error payload thrown by `err()` and inspected via `extractErrorData()` / `getErrorCode()`.FileUploadExports — lib/noboil/src/spacetimedb/server/types/file.ts
makeFileUpload(spacetimedb, {
namespace: 'avatars',
fields: { contentType: t.string(), data: t.array(t.u8()), filename: t.string(), size: t.u32() },
idField: t.u32(),
pk: tbl => tbl.id,
table: db => db.file,
maxFileSize: 5 * 1024 * 1024
})KvExports — lib/noboil/src/spacetimedb/server/kv.ts
makeKv(spacetimedb, {
tableName: 'siteConfig',
keyField: t.string(),
fields: { value: t.string() },
table: db => db.siteConfig,
options: { softDelete: true }
})KvSchema — lib/noboil/src/convex/server/types.ts
// convex/siteConfig.ts
import { makeKv } from 'noboil/convex/server'
import { schemas } from './schema'
export const { get, list, set, rm, restore } = makeKv({
builders, schema: schemas.siteConfig.banner, table: 'siteConfig',
keys: ['banner', 'maintenanceMode'] as const, softDelete: true
})LogExports — lib/noboil/src/spacetimedb/server/log.ts
makeLog(spacetimedb, {
tableName: 'message',
parentField: t.string(),
idempotencyKeyField: t.string(),
fields: { text: t.string() },
table: db => db.message,
options: { rateLimit: { max: 30, window: 60_000 } }
})LogSchema — lib/noboil/src/convex/server/types.ts
// convex/message.ts
export const { append, list, listAfter, purgeByParent } = makeLog({
builders, schema: schemas.message, table: 'message',
options: { rateLimit: 30, search: 'text' }
})makeCrud — lib/noboil/src/spacetimedb/server/crud.ts
const reducers = makeCrud(spacetimedb, { tableName: 'post', fields, idField, pk, table })makeFileUpload — lib/noboil/src/spacetimedb/server/file.ts
const uploads = makeFileUpload(spacetimedb, { namespace: 'avatars', fields, idField, pk, table })noboil — lib/noboil/src/convex/server/noboil.ts
import { noboil } from './'
import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'
import { getAuthUserId } from '@convex-dev/auth/server'
import { s } from './s'
export const api = noboil({
query, mutation, action, internalQuery, internalMutation, getAuthUserId,
orgSchema: s.team,
tables: ({ table }) => ({
blog: table(s.blog, { rateLimit: 10, search: 'content' }),
wiki: table(s.wiki, { acl: true, softDelete: true }),
profile: table(s.profile),
movie: table(s.movie, { key: 'tmdbId', ttl: 86_400 })
})
})
// Then in convex/blog.ts:
import { api } from './lazy'
export const { create, update, rm, pub: { list, read, search } } = api.blogOrgCrudExports — lib/noboil/src/spacetimedb/server/types/org-crud.ts
makeOrgCrud(spacetimedb, {
tableName: 'project',
fields: { name: t.string() },
idField: t.u32(),
pk: tbl => tbl.id,
table: db => db.project,
orgTable: db => db.org,
memberTable: db => db.orgMember,
options: { acl: true, softDelete: true }
})PaginationOptsShape — lib/noboil/src/convex/server/types.ts
// convex/project.ts
import { makeOrgCrud } from 'noboil/convex/server'
import { schemas } from './schema'
export const { create, list, read, update, rm, addEditor, removeEditor, editors } = makeOrgCrud({
builders, schema: schemas.project, table: 'project', options: { acl: true, softDelete: true }
})QuotaEntry — lib/noboil/src/convex/server/types.ts
// convex/pollVoteQuota.ts
export const { check, consume, record } = makeQuota({
builders, schema: schemas.pollVoteQuota, table: 'pollVoteQuota',
options: { durationMs: 60_000, limit: 10 }
})QuotaExports — lib/noboil/src/spacetimedb/server/quota.ts
makeQuota(spacetimedb, {
tableName: 'pollVoteQuota',
ownerField: t.string(),
table: db => db.pollVoteQuota,
durationMs: 60_000,
limit: 10
})resolveToastError — lib/noboil/src/spacetimedb/react/use-mutate.ts
const save = useMutate(api.posts.update, { optimistic: true })SetupConfig — lib/noboil/src/convex/server/types.ts
// convex/_setup.ts
import { setup } from 'noboil/convex/server'
import { mutation, query, action, internalMutation, internalQuery } from './_generated/server'
import { auth } from './auth'
export const { m, q, cm, cq, action: a, ... } = setup({
action, mutation, query, internalMutation, internalQuery,
getAuthUserId: ctx => auth.getUserId(ctx),
middleware: [composeMiddleware(auditLog, slowQueryWarn)]
})SingletonExports — lib/noboil/src/spacetimedb/server/types/singleton.ts
makeSingletonCrud(spacetimedb, {
tableName: 'profile',
fields: { bio: t.string().optional(), name: t.string().optional() },
table: db => db.profile
})SKIP_RESULT — lib/noboil/src/spacetimedb/react/use-infinite-list.ts
const list = useInfiniteList(rows, ready, { batchSize: 25 })SKIP_RESULT — lib/noboil/src/spacetimedb/react/use-list.ts
const list = useList(rows, ready, {
pageSize: 20,
where: { own: true },
search: { query: 'hello', fields: ['title', 'content'] }
})useList — lib/noboil/src/convex/react/use-list.ts
const { data, loadMore, isDone } = useList(api.blog.list, { where: { published: true } })useMutate — lib/noboil/src/convex/react/use-mutate.ts
const update = useMutate(api.blog.update)
const remove = useMutate(api.blog.rm, { onError: false })useOrgQuery — lib/noboil/src/convex/react/org.tsx
const wikis = useOrgQuery(api.wiki.list)useOwnRows — lib/noboil/src/spacetimedb/react/use-list.ts
const blogs = useOwnRows(allBlogs, identity ? b => b.userId.isEqual(identity) : null)usePresence — lib/noboil/src/convex/react/use-presence.ts
const { users, updatePresence } = usePresence(presenceRefs, chatId, { data: { cursor: { x, y } } })zodFromTable — lib/noboil/src/spacetimedb/stdb-zod.ts
const schema = zodFromTable(module.table.columns, { optional: ['bio'] })