API Reference
Complete API reference for noboil/convex and noboil/spacetimedb — server factories, React hooks, utilities, and error codes.
Installation
bun add noboilPeer dependencies: convex, convex-helpers, zod, @tanstack/react-form, react.
bun add noboilPeer dependencies: spacetimedb, zod, @tanstack/react-form, react.
Imports
| Module | Key Exports |
|---|---|
noboil/convex | ACTIVE_ORG_COOKIE, ACTIVE_ORG_SLUG_COOKIE, Ab, ActionCtxLike, Api, AuthorInfo, BULK_MAX, BYTES_PER_KB, BYTES_PER_MB, CacheCrudResult, CacheOptions, CanEditOpts, CascadeOption, ChildConfig, ChildCrudResult, ComparisonOp, ConflictData, ConvexErrorData, CrudHooks, CrudOptions, CrudReadApi, CrudResult, DbLike, DbReadLike, DefType, DevError, DevSubscription, DocBase, EnrichedDoc, ErrorCode, ErrorData, ErrorHandler, FID, FieldKind, FieldMeta, FieldMetaMap, FileKind, FormReturn, HookCtx, Mb, MutationCtxLike, ONE_YEAR_SECONDS, OrgContextValue, OrgCrudResult, OrgDoc, OrgEnrichedDoc, OrgProviderProps, OrgRole, PaginatedResult, PaginationOptsShape, Qb, QueryCtxLike, QueryLike, ReadCtx, SetupConfig, SoftDeleteOpts, StorageLike, ToastFn, UNDO_MS, WhereGroupOf, WhereOf, WithUrls, ZodSchema, guardApi, isCvxTestMode, isPlaywright, isStdbTestMode, sleep |
noboil/convex/server | AuditAppendInput, AuditExports, AuditHooks, AuditRow, BudgetAuditSummary, BudgetCheckResult, BudgetExports, BudgetHooks, BudgetReserveResult, CHUNK_SIZE, ConvexErrorData, DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_FILE_SIZE, ErrorData, ErrorHandler, HEARTBEAT_INTERVAL_MS, InviteDocLike, JoinRequestItem, MutationFail, MutationOk, MutationResult, OrgDocLike, OrgMemberItem, OrgUserLike, PRESENCE_TTL_MS, auditLog, baseTable, canEdit, checkRateLimit, checkSchema, childTable, composeMiddleware, err, extractErrorData, fail, getErrorCode, getErrorDetail, getErrorDocsUrl, getErrorMessage, getErrorSuggestion, getOrgMember, getOrgRole, handleConvexError, handleError, idEquals, inputSanitize, isErrorCode, isMutationError, isRecord, kvTable, logTable, makeAudit, makeBudget, makeCacheCrud, makeChildCrud, makeCrud, makeFileUpload, makeKv, makeLog, makeOrg, makeOrgCrud, makePresence, makeQuota, makeSingletonCrud, matchError, noboil, normalizeRateLimit, ok, orgCascade, orgChildTable, orgTable, orgTables, ownedCascade, ownedTable, periodKeyFor, presenceTable, quotaTable, rateLimitTable, requireOrgMember, requireOrgRole, setup, singletonTable, slowQueryWarn, time, uploadTables |
noboil/convex/zod | DefType, FileKind, ZodSchema, coerceOptionals, defaultValue, defaultValues, elementOf, enumToOptions, fileKindOf, fileMetaOf, isArrayType, isBooleanType, isDateType, isNumberType, isOptionalField, isStringType, pickValues, requiredPartial, unwrapZod |
noboil/convex/retry | RetryOptions, fetchWithRetry, withRetry |
noboil/convex/schema | child, file, files, makeBase, makeKv, makeLog, makeOrg, makeOrgScoped, makeOwned, makeQuota, makeSingleton, orgSchema, schema |
noboil/convex/react | Api, BulkMutateToast, BulkProgress, BulkResult, ConflictData, ConvexCrudRefs, ConvexKvRefs, ConvexLogRefs, ConvexQuotaRefs, ConvexSingletonRefs, Devtools, DevtoolsAutoMount, DevtoolsProps, ErrorToastOptions, FieldKind, FieldMeta, FieldMetaMap, FormReturn, InfiniteListOptions, KvHookResult, ListItems, LogHookResult, MutateOptions, MutateToast, MutationType, OptimisticOptions, OptimisticProvider, OrgContextValue, OrgDoc, OrgProvider, OrgProviderProps, PendingMutation, PlaygroundProps, PresenceRefs, PresenceUser, QuotaHookResult, QuotaState, SLOW_THRESHOLD_MS, STALE_THRESHOLD_MS, SchemaPlayground, SingletonHookResult, SoftDeleteOpts, ToastFn, UseBulkMutateOptions, UseBulkSelectionOpts, UseCacheEntryOptions, UseCacheEntryResult, UseListOptions, UsePresenceOptions, UsePresenceResult, UseSearchOptions, UseSearchResult, buildMeta, canEditResource, clearErrors, clearMutations, completeMutation, createApi, createOrgHooks, defaultOnError, getMeta, makeErrorHandler, pushError, setActiveOrgCookieClient, toastFieldError, trackCacheAccess, trackMutation, trackSubscription, untrackSubscription, updateSubscription, updateSubscriptionData, useActiveOrg, useBulkMutate, useBulkSelection, useCacheEntry, useCrud, useDevErrors, useErrorToast, useForm, useFormMutation, useInfiniteList, useKv, useList, useLog, useMutate, useMyOrgs, useOnlineStatus, useOptimisticMutation, useOrg, useOrgMutation, useOrgQuery, useOwnRows, usePendingMutations, usePresence, useQuota, useSearch, useSingleton, useSoftDelete, useUpload |
noboil/convex/components | Api, AutoForm, AutoSaveIndicator, ConflictDialog, EditorsSection, ErrorBoundary, FileApiContext, FileApiProvider, FileFieldImpl, Form, FormContext, OfflineIndicator, OrgAvatar, PermissionGuard, RoleBadge, ServerFieldError, defineSteps, deriveLabel, fields, useForm, useFormMutation |
noboil/convex/eslint | plugin, recommended, rules |
noboil/convex/next | clearActiveOrgCookie, getActiveOrg, getToken, isAuthenticated, makeImageRoute, setActiveOrgCookie |
noboil/convex/test | OrgTestCrudConfig, TEST_EMAIL, TestAuthConfig, TestUser, createTestContext, getOrgMembership, isTestMode, makeOrgTestCrud, makeTestAuth |
noboil/convex/test/discover | discoverModules |
noboil/convex/tools | ActionCtxExtras, ArgSpec, ArgSpecs, BuilderDeps, CALLER_AUTH, Called, CommonOpts, CostClass, DefineMutationOpts, DefineQueryOpts, DefineToolOpts, DispatchError, ErrorCategory, FailArg, FailFn, HandlerArgs, HermeticHandler, IntrospectedValidator, KnownErrorCode, ManifestArg, ManifestCommand, ManifestNode, ProviderMeta, ReadCtxExtras, RegistryEntry, SchemaNode, Step, StepSink, ToolError, ToolKind, ToolListOpts, ToolMeta, Wrapped, WrappedErr, WrappedOk, arg, buildArgs, buildTree, callResult, createBuilder, createStepSink, defineProvider, errorRes, findCommand, findValidPath, hermeticTry, introspect, jsonRes, makeError, makeFail, newTrace, newTraceId, parsePath, setHermeticAdapter, snakeArgs, toDispatchError, toolListBlock, unwrap, validateArgs, wrapArgs |
noboil/convex/tools/codegen | Extracted, SchemaNode, ToolFile, collect, emitRegistry, emitToolCallers, emitToolTypes, extractSchemas |
noboil/convex/seed | generateFieldValue, generateOne, generateSeed |
| Module | Key Exports |
|---|---|
noboil/spacetimedb | ACTIVE_ORG_COOKIE, ACTIVE_ORG_SLUG_COOKIE, Ab, ActionCtxLike, AuthorInfo, BULK_MAX, BYTES_PER_KB, BYTES_PER_MB, BaseSchema, CacheCrudResult, CacheOptions, CanEditOpts, CascadeOption, ChildConfig, ChildCrudResult, ComparisonOp, CrudHooks, CrudOptions, CrudReadApi, CrudResult, DEFAULT_HTTP_URI, DEFAULT_PORT, DEFAULT_TOKEN_KEY, DEFAULT_WS_URI, DbLike, DbReadLike, DocBase, EnrichedDoc, ErrorCode, FID, HookCtx, InferCreate, InferReducerArgs, InferReducerInputs, InferReducerOutputs, InferReducerReturn, InferRow, InferRows, InferUpdate, Mb, MutationCtxLike, ONE_YEAR_SECONDS, OrgCrudResult, OrgDefSchema, OrgEnrichedDoc, OrgRole, OrgSchema, OwnedSchema, PaginatedResult, PaginationOptsShape, Qb, QueryCtxLike, QueryLike, ReadCtx, Register, RegisteredDefaultError, RegisteredMeta, SchemaPhantoms, SetupConfig, SingletonSchema, StorageLike, TOKEN_COOKIE_KEY, UNDO_MS, WhereGroupOf, WhereOf, WithUrls, guardApi, idEquals, idFromWire, idToWire, identityEquals, identityFromHex, identityToHex, isCvxTestMode, isPlaywright, isStdbTestMode, sleep, wsToHttp, zodFromTable |
noboil/spacetimedb/server | CHUNK_SIZE, CrudDefaults, DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_FILE_SIZE, ErrorData, ErrorHandler, HEARTBEAT_INTERVAL_MS, InviteDocLike, JoinRequestItem, MutationFail, MutationOk, MutationResult, OrgDocLike, OrgMemberItem, OrgRole, OrgTypeBuilders, OrgUserLike, PRESENCE_TTL_MS, StdbDeps, TestContext, TestUser, asUser, auditLog, baseTable, callReducer, canEdit, checkMembership, checkRateLimit, checkSchema, childTable, cleanup, composeMiddleware, createTestContext, createTestUser, discoverModules, err, errValidation, extractErrorData, fail, getErrorCode, getErrorDetail, getErrorDocsUrl, getErrorMessage, getErrorSuggestion, handleError, inputSanitize, isErrorCode, isMutationError, isRecord, isTestMode, kvTable, logTable, makeCacheCrud, makeChildCrud, makeCrud, makeFileUpload, makeKv, makeLog, makeOrg, makeOrgCrud, makeOrgTables, makePresence, makeQuota, makeSchema, makeSingletonCrud, makeUnique, matchError, noboil, ok, orgCascade, orgChildTable, orgTable, orgTables, ownedCascade, ownedTable, presenceTable, queryTable, quotaTable, rateLimitTable, requireOrgMember, setup, setupCrud, singletonTable, slowQueryWarn, time, uploadTables, warnLargeFilterSet, zodToStdbFields |
noboil/spacetimedb/zod | DefType, FileKind, UndefinedToOptional, ZodSchema, coerceOptionals, defaultValue, defaultValues, elementOf, enumToOptions, fileKindOf, fileMetaOf, isArrayType, isBooleanType, isDateType, isNumberType, isOptionalField, isStringType, partialValues, pickValues, requiredPartial, schemaVariants, unwrapZod |
noboil/spacetimedb/retry | RetryOptions, fetchWithRetry, withRetry |
noboil/spacetimedb/schema | child, file, files, makeBase, makeKv, makeLog, makeOrg, makeOrgScoped, makeOwned, makeQuota, makeSingleton, orgSchema, schema |
noboil/spacetimedb/react | ActiveOrgState, BulkMutateToast, BulkProgress, BulkResult, ConflictData, CreateSpacetimeClientOptions, Devtools, DevtoolsAutoMount, DevtoolsProps, ErrorData, ErrorHandler, ErrorToastOptions, FieldKind, FieldMeta, FieldMetaMap, FileProvider, FileRow, FormReturn, InfiniteListOptions, InfiniteListResult, InfiniteListWhere, KvHookResult, KvRowBase, ListSort, ListWhere, LogHookResult, LogRowBase, MutateOptions, MutateToast, MutationFail, MutationOk, MutationResult, MutationType, OptimisticOptions, OptimisticProvider, OrgContextValue, OrgDoc, OrgMembership, OrgProvider, OrgProviderProps, PendingMutation, PlaygroundProps, PresenceHeartbeatArgs, PresenceRefs, PresenceUser, QuotaHookResult, QuotaRowBase, QuotaState, SLOW_THRESHOLD_MS, STALE_THRESHOLD_MS, SchemaPlayground, SingletonHookResult, SingletonRowBase, SkipInfiniteListResult, SkipListResult, SoftDeleteOpts, SortDirection, SortMap, SortObject, SpacetimeConnectionBuilder, SpacetimeConnectionFactory, StdbCrudRefs, StdbKvRefs, StdbLogRefs, StdbQuotaRefs, StdbSingletonRefs, ToastFn, TokenStore, TypedFieldErrors, UseBulkMutateOptions, UseBulkSelectionOpts, UseCacheEntryOptions, UseCacheEntryResult, UseListOptions, UseListResult, UsePresenceOptions, UsePresenceResult, UseSearchOptions, UseSearchResult, WhereFieldValue, WhereGroup, Widen, buildMeta, canEditResource, clearErrors, clearMutations, completeMutation, completeReducerCall, createApi, createFileUploader, createOrgHooks, createSpacetimeClient, createTokenStore, defaultOnError, extractErrorData, fail, fileBlobUrl, getErrorCode, getErrorDetail, getErrorDocsUrl, getErrorMessage, getErrorSuggestion, getFieldErrors, getFirstFieldError, getMeta, handleError, injectError, isErrorCode, isMutationError, makeErrorHandler, matchError, noop, ok, pushError, resolveFileUrl, setActiveOrgCookieClient, toWsUri, toastFieldError, trackCacheAccess, trackMutation, trackReducerCall, trackSubscription, untrackSubscription, updateSubscription, updateSubscriptionData, useActiveOrg, useBulkMutate, useBulkSelection, useCacheEntry, useCrud, useDevErrors, useErrorToast, useFileUrl, useFiles, useForm, useFormMutation, useInfiniteList, useKv, useList, useLog, useMut, useMutate, useMutation, useMyOrgs, useOnlineStatus, useOptimisticMutation, useOrg, useOrgMutation, useOrgQuery, useOwnRows, usePendingMutations, usePresence, useQuota, useResolveFileUrl, useSearch, useSingleton, useSoftDelete, useStdbHydrated, useUpload |
noboil/spacetimedb/components | Api, AutoForm, AutoSaveIndicator, ConflictDialog, EditorsSection, ErrorBoundary, FileApiContext, FileApiProvider, FileFieldImpl, Form, FormContext, OfflineIndicator, OrgAvatar, PermissionGuard, RoleBadge, ServerFieldError, UploadOptions, UploadResponse, defineSteps, deriveLabel, fields, useForm, useFormMutation |
noboil/spacetimedb/eslint | plugin, recommended, rules |
noboil/spacetimedb/next | ActiveOrgQuery, SqlQueryConfig, TableQueryConfig, clearActiveOrgCookie, getActiveOrg, getToken, isAuthenticated, makeImageRoute, queryTable, setActiveOrgCookie |
noboil/spacetimedb/test | ErrorData, TestContext, TestUser, asUser, callReducer, cleanup, createTestContext, createTestUser, extractErrorData, getErrorCode, getErrorDetail, getErrorMessage, isTestMode, queryTable |
noboil/spacetimedb/test/discover | discoverModules |
noboil/spacetimedb/seed | generateFieldValue, generateOne, generateSeed |
Server factories
noboil()
The high-level entry point. Wraps setup() and provides a table() helper that dispatches to the correct factory based on schema brand:
import { noboil } from 'noboil/convex/server'
import { s } from './s'
const api = noboil({
query, mutation, action, internalQuery, internalMutation, getAuthUserId,
tables: ({ table }) => ({
blog: table(s.blog, { rateLimit: { max: 10, window: 60_000 }, search: 'content' }),
profile: table(s.blogProfile),
project: table(s.project, { acl: true }),
message: table(s.message, { pub: { parentField: 'isPublic' } })
})
})table() detects the schema brand (owned, org, singleton, base, child) and calls the correct factory. The result object (api.blog, api.profile, etc.) contains the generated endpoints.
Access lower-level builders via api.setup:
const { pq, q, m } = api.setupsetup()
Lower-level entry point. Call once in a shared file and destructure crud, orgCrud, pq, q, m, and other builders:
import { setup } from 'noboil/convex/server'
import { getAuthUserId } from '@convex-dev/auth/server'
import {
action,
internalMutation,
internalQuery,
mutation,
query
} from './_generated/server'
const { crud, orgCrud, singletonCrud, cacheCrud, pq, q, m } = setup({
query,
mutation,
action,
internalQuery,
internalMutation,
getAuthUserId
})crud()
Generates CRUD endpoints for a user-owned table:
export const { create, list, read, rm, update } = crud(
'blog',
owned.blog,
{
rateLimit: { max: 10, window: 60_000 },
search: 'content',
softDelete: true
}
)With public read access:
export const {
create,
rm,
update,
pub: { list, read, search }
} = crud('blog', owned.blog, { search: 'content' })orgCrud()
Generates org-scoped CRUD with optional ACL and cascade delete:
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' })
})singletonCrud()
One row per user (profile, settings):
export const { get, upsert } = singletonCrud('profile', singleton.profile)cacheCrud()
External data cache with TTL and auto-fetch:
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}`)
return res.json()
}
})log() — append-only event log
Atomic per-parent seq, idempotent appends, soft-delete + restore, bulk operations. See log factory.
// backend/convex/lazy.ts — register
tables: ({ table }) => ({
vote: table(s.vote, { softDelete: true, rateLimit: 30 })
})
// backend/convex/convex/vote.ts — re-export
export const {
append, appendBulk, auth, list, listAfter, purgeByParent, read,
restoreByParent, rm, update
} = api.votekv() — string-keyed global state
Public reads, role-gated writes, soft-delete + restore, optional key whitelist, conflict detection. See kv factory.
tables: ({ table }) => ({
siteConfig: table(s.siteConfig, { softDelete: true })
})
export const { get, list, restore, rm, set } = api.siteConfigquota() — sliding-window rate limit
check/record/consume triad with hooks (beforeConsume, onExceeded, etc.). See quota factory.
tables: ({ table }) => ({
pollVoteQuota: table(s.pollVoteQuota)
})
export const { check, consume, record } = api.pollVoteQuotanoboil()
The primary entry point. Pass a factory function that receives table and t helpers:
import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table }) => ({
blog: table(s.blog, { pub: 'published' }),
profile: table(s.profile),
project: table(s.project),
team: table(s.team, { unique: ['slug'] }),
movie: table(s.movie, { key: 'tmdbId' }),
message: table(s.message)
}) })table() — universal interface
table() detects the schema brand and applies the correct system fields automatically:
const blog = table(owned.blog, { pub: 'published' })
const project = table(orgScoped.project)
const org = table(org.team, { unique: ['slug'] })
const profile = table(singleton.profile)
const message = table(children.message)
const movie = table(base.movie, { key: 'tmdbId' })
const file = table.file()Options by table type:
| Table type | Available options |
|---|---|
| owned | index, unique, extra, softDelete, rateLimit, pub |
| orgScoped | index, unique, extra, softDelete, rateLimit, cascade, indexes, pub |
| org | index, unique, extra |
| base/cache | key, ttl |
| singleton | none |
| child | none |
| log | softDelete, rateLimit, pub (and per-row pub: { parentField }), hooks, withAuthor |
| kv | softDelete, rateLimit, hooks, cleanFiles |
| quota | hooks (beforeConsume/afterConsume/beforeRecord/afterRecord/onExceeded) |
Generated reducers for owned tables:
| Reducer | Parameters | Description |
|---|---|---|
create_{tableName} | All fields | Insert a row. Sets userId = ctx.sender, updatedAt = ctx.timestamp. |
update_{tableName} | id, optional fields, optional expectedUpdatedAt | Update a row. Caller must be the owner. Throws CONFLICT if expectedUpdatedAt doesn't match. |
rm_{tableName} | id | Delete a row (or soft-delete if softDelete: true). Caller must be the owner. |
Read-side access control (pub option):
table(s.blog, { pub: 'published' }) // visible when published=true OR own row
table(s.chat, { pub: 'isPublic' }) // visible when isPublic=true OR own row
table(s.chat, { pub: true }) // fully public, no RLS filter
table(s.blog) // private — only own rows visibleFactory options (auto-derived from LogOptions/KvOptions interfaces in source):
log
| Option | Type | Required |
|---|---|---|
hooks | LogHooks<DB> | no |
rateLimit | RateLimitConfig | no |
softDelete | boolean | no |
kv
| Option | Type | Required |
|---|---|---|
hooks | KvHooks<DB> | no |
rateLimit | RateLimitConfig | no |
softDelete | boolean | no |
quota
(no options — only { durationMs, limit } config in schema)
Generated endpoints for log/kv/quota tables (auto-derived from lib/noboil/src/convex/server/{log,kv,quota}.ts b.q()/b.m() declarations + return typed({...}) exports):
| Factory | Convex-generated endpoints |
|---|---|
log | append (mutation), authIndexed (query), authList (query), listAfter (query), purgeByParent (mutation), read (query), rmOne (mutation), update (mutation) |
kv | get (query), list (query), rm (mutation), set (mutation) |
quota | check (query), consume (mutation), record (mutation) |
Generated reducers for log/kv/quota tables (auto-derived from lib/noboil/src/spacetimedb/server/{log,kv,quota}.ts):
| Factory | Reducer | Parameters | Description |
|---|---|---|---|
log | append_{table} | parent, ...payload, optional idempotency_key | Atomic seq + insert. Idempotent if idempotency_key provided. |
bulk_append_{table} | parent, items[] | Single-transaction batch insert. | |
purge_{table}_by_parent | parent | Soft- or hard-delete all rows for a parent. | |
restore_{table}_by_parent | parent | Bring back soft-deleted rows for a parent. Requires softDelete: true. | |
rm_{table} | id | Hard-delete a single row. Author-only. | |
bulk_rm_{table} | ids[] | Hard-delete multiple rows. Author-only. | |
update_{table} | id, ...payload, optional expectedUpdatedAt | Update a row's payload. Preserves seq + parent. Author-only. Throws CONFLICT on stale expectedUpdatedAt. | |
kv | set_{table} | key, ...payload, optional expectedUpdatedAt | Upsert with optional conflict check. Throws CONFLICT on stale expectedUpdatedAt. |
rm_{table} | key | Soft- or hard-delete by key. | |
restore_{table} | key | Bring back a soft-deleted key. Requires softDelete: true. | |
quota | consume_{table} | owner | Atomic check + record. Throws when over limit. |
record_{table} | owner | Append timestamp + prune expired. Always succeeds. |
makePresence
Generates presence heartbeat, leave, and cleanup reducers:
import { makePresence } from 'noboil/spacetimedb/server'
const presenceFns = makePresence(spacetimedb, {
dataField: t.string(),
roomIdField: t.string(),
pk: tbl => tbl.id,
table: db => db.presence,
tableName: 'presence'
})| Reducer | Parameters | Description |
|---|---|---|
presence_heartbeat_{tableName} | roomId, optional data | Upsert presence row. |
presence_leave_{tableName} | roomId | Delete presence row for the caller. |
presence_cleanup_{tableName} | none | Delete stale presence rows (older than TTL). |
Constants: HEARTBEAT_INTERVAL_MS = 15000, PRESENCE_TTL_MS = 30000.
makeFileUpload
Generates reducers for registering and deleting uploaded files:
import { makeFileUpload } from 'noboil/spacetimedb/server'
const fileCrud = makeFileUpload(spacetimedb, {
fields: {
contentType: t.string(),
filename: t.string(),
size: t.number(),
data: t.byteArray()
},
idField: t.u32(),
namespace: 'file',
pk: tbl => tbl.id,
table: db => db.file,
options: {
maxFileSize: 10 * 1024 * 1024,
allowedTypes: new Set(['image/jpeg', 'image/png', 'application/pdf'])
}
})React hooks
Auto-derived from lib/noboil/src/{convex,spacetimedb}/react/use-*.ts filenames:
| Hook | Convex | SpacetimeDB |
|---|---|---|
usebulkMutate | ✓ | ✓ |
usebulkSelection | ✓ | ✓ |
usecache | ✓ | ✓ |
usecrud | ✓ | ✓ |
usehydrated | — | ✓ |
useinfiniteList | ✓ | ✓ |
usekv | ✓ | ✓ |
uselist | ✓ | ✓ |
uselog | ✓ | ✓ |
usemutate | ✓ | ✓ |
useonlineStatus | ✓ | ✓ |
useoptimistic | ✓ | ✓ |
usepresence | ✓ | ✓ |
usequota | ✓ | ✓ |
usesearch | ✓ | ✓ |
usesingleton | ✓ | ✓ |
usesoftDelete | ✓ | ✓ |
useupload | ✓ | ✓ |
React hooks exported from /react:
import { useList, useOwnRows, usePresence, useInfiniteList, useSearch } from 'noboil/convex/react'import { useList, useOwnRows, usePresence, useInfiniteList, useSearch } from 'noboil/spacetimedb/react'useList
Client-side filtering, sorting, and pagination over subscription data.
const { data, hasMore, isLoading, loadMore, page, totalCount } = useList(
rows,
isReady,
{
where: {
published: true,
category: 'tech',
or: [{ category: 'news' }]
},
sort: { field: 'updatedAt', direction: 'desc' },
search: { query: 'hello', fields: ['title', 'content'] },
pageSize: 20
}
)Pass 'skip' to disable execution:
const list = useList(rows, ready, someCondition ? { where: { published: true } } : 'skip')Type-safe where — field names are checked against the row type at compile time:
useList(blogs, ready, { where: { publishd: true } }) // TS error: 'publishd' doesn't exist
useList(items, ready, { where: { price: { $gt: 10 } } }) // OKReturn value:
| Field | Type | Description |
|---|---|---|
data | T[] | Current page of filtered, sorted rows |
hasMore | boolean | Whether more rows exist |
isLoading | boolean | true until isReady is true |
loadMore | () => void | Load the next page |
page | number | Current page number |
totalCount | number | Total filtered row count |
useLog
Append-only log subscription. Pairs with log factory.
import { useLog } from 'noboil/convex/react'
const log = useLog(api.vote, { parent: pollId })
log.data // Row[]
log.hasMore
await log.loadMore(20)
await log.append({ payload: { optionIdx: 0, voter: 'me' } })
await log.appendBulk([{ optionIdx: 0, voter: 'a' }])
await log.update(rowId, { optionIdx: 1 })
await log.rm(rowId)
await log.purge() // soft-delete all rows for parent (when softDelete: true)
await log.restore()useKv
Single-key value subscription. Pairs with kv factory.
import { useKv } from 'noboil/convex/react'
const banner = useKv(api.siteConfig, 'banner')
banner.data // Row | null | undefined
await banner.update({ active: true, message: 'hi' })
await banner.update(payload, { expectedUpdatedAt }) // conflict-checked
await banner.remove()
await banner.restore()useQuota
Sliding-window rate limit subscription. Pairs with quota factory.
import { useQuota } from 'noboil/convex/react'
const quota = useQuota(api.pollVoteQuota, pollId)
quota.state // { allowed, remaining, retryAfter? }
const result = await quota.consume()
const after = await quota.record()Hook return interfaces (auto-generated from source)
useLog (convex)
{
append: (args: { idempotencyKey?: string; payload: Partial<T>
}useKv (convex)
{
data: null | T | undefined
remove: () => Promise<void>
restore: () => Promise<void>
update: (payload: T, opts?: { expectedUpdatedAt?: number
}useQuota (convex)
{
consume: () => Promise<QuotaState>
record: () => Promise<QuotaState>
state: QuotaState | undefined
}useLog (spacetimedb)
{
append: (args: { idempotencyKey?: string; payload: Partial<T>
}useKv (spacetimedb)
{
data: null | T
isLoading: boolean
remove: () => Promise<void>
restore: () => Promise<void>
update: (payload: Partial<T>, opts?: { expectedUpdatedAt?: unknown
}useQuota (spacetimedb)
{
consume: () => Promise<void>
record: () => Promise<void>
state: QuotaState
}Hook parameter signatures (auto-generated from source)
17 hooks (parameter list before generic constraints):
| Hook | Params |
|---|---|
useBulkMutate | (mutate: (args: A) => Promise<R>, options?: UseBulkMutateOptions) |
useBulkSelection | (options: UseBulkSelectionOpts) |
useCacheEntry | (\{ args, get: getRef, load: loadRef \}: UseCacheEntryOptions<Q, A>) |
useCrud | (refs: R, options?: CrudOptions) |
useInfiniteList | (query: F, ...rest: InfiniteListRest<F>) |
useKv | (refs: R, key: string) |
useList | (query: F, ...rest: ListRest<F>) |
useLog | (refs: R, args: \{ parent: string \}) |
useMutate | (ref: T, options?: MutateOptions) |
useOptimisticMutation | (\{ mutation, onOptimistic, onRollback, onSettled, onSuccess \}: OptimisticOptions<T>) |
useOwnRows | `(rows: readonly T[], isOwn: ((row: T) => boolean) \ |
usePresence | (refs: PresenceRefs, roomId: string, options?: UsePresenceOptions) |
useQuota | (refs: ConvexQuotaRefs, owner: string) |
useSearch | (searchRef: F, argsBuilder: (query: string) => OptionalRestArgs<F>[0], options?: UseSearchOptions) |
useSingleton | (refs: R) |
useSoftDelete | (options: SoftDeleteOpts<A>) |
useUpload | (uploadMutation: FunctionReference<'mutation'>, options?: UploadOptions) |
14 hooks:
| Hook | Params |
|---|---|
useBulkMutate | (mutate: (args: A) => Promise<R>, options?: UseBulkMutateOptions) |
useBulkSelection | (options: UseBulkSelectionOpts) |
useInfiniteList | `(data: T[], isReady: boolean, options?: 'skip' \ |
useKv | (refs: StdbKvRefs, key: string) |
useList | `(data: readonly T[], isReady: boolean, options?: 'skip' \ |
useLog | (refs: StdbLogRefs, args: \{ parent: string \}) |
useOwnRows | `(rows: readonly T[], isOwn: ((row: T) => boolean) \ |
usePresence | (data: PresenceRow[], heartbeat: (args?: PresenceHeartbeatArgs) => Promise<void>, options?: UsePresenceOptions) |
useQuota | (refs: StdbQuotaRefs, owner: string) |
useSearch | `(data: T[], isReady: boolean, options: 'skip' \ |
useSingleton | `(refs: StdbSingletonRefs, sender: Identity \ |
useSoftDelete | (options: SoftDeleteOpts<A>) |
useStdbHydrated | `(queries: QueryInput \ |
useUpload | (config?: UploadConfig) |
useOwnRows
Computes an own boolean on each row using a predicate function. Pass null when the current user's identity is not yet known — all rows will have own: false.
import { useOwnRows } from 'noboil/convex/react'
const blogs = useOwnRows(allBlogs, me ? b => b.userId === me._id : null)import { useOwnRows } from 'noboil/spacetimedb/react'
const blogs = useOwnRows(
allBlogs,
identity ? b => b.userId.isEqual(identity) : null
)Combine with useList and where: { own: true } to show only the current user's rows:
const ownedBlogs = useOwnRows(allBlogs, isOwn)
const { data } = useList(ownedBlogs, isReady, { where: { own: true } })usePresence
Manages presence state with heartbeat and TTL-based cleanup.
import { usePresence } from 'noboil/convex/react'
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 },
})
updatePresence({ cursor: { x: 100, y: 200 }, status: 'editing' })import { usePresence } from 'noboil/spacetimedb/react'
import { useTable, useReducer } from 'spacetimedb/react'
import { tables, reducers } from '@/generated/module_bindings'
const [presenceRows, isReady] = useTable(tables.presence)
const heartbeat = useReducer(reducers.presence_heartbeat_presence)
const { users, updatePresence } = usePresence(
presenceRows,
async ({ data } = {}) => heartbeat({ roomId: 'main', data: JSON.stringify(data ?? {}) }),
{ enabled: true, heartbeatIntervalMs: 15_000, ttlMs: 30_000 }
)
updatePresence({ cursor: { x: 100, y: 200 } })updatePresence(data) stores the latest payload and sends it on the next heartbeat. Use the heartbeat callback's optional { data } parameter to serialize and forward that payload to your reducer.
useInfiniteList
Like useList but designed for infinite scroll. Accepts the same type-safe where and search options. When where or search.query changes, the visible count resets automatically.
const { data, hasMore, loadMore, totalCount } = useInfiniteList(rows, isReady, {
batchSize: 20,
sort: { field: 'updatedAt', direction: 'desc' },
where: { published: true },
search: { query: searchInput, fields: ['title', 'content'], debounceMs: 300 }
})useSearch
Client-side full-text search. fields is type-checked against the row type:
const results = useSearch(posts, query, {
fields: ['title', 'content'] // TS error if field name is invalid
})useSoftDelete
Wraps a delete+restore reducer pair with an undo toast pattern:
const { remove } = useSoftDelete({
rm: removeWiki,
restore: restoreWiki,
toast: toast,
label: 'Wiki page',
undoMs: 5000
})
await remove({ id: wikiId })useBulkSelection
Multi-select state management with built-in bulk delete and optional undo:
const { selected, toggleSelect, toggleSelectAll, clear, handleBulkDelete } =
useBulkSelection({
items: projects,
orgId,
rm: id => rmProject({ id: Number(id) }),
restore: args => restoreProject({ id: Number(args.id) }),
toast: (msg, opts) => toast(msg, opts),
undoLabel: 'project',
onSuccess: count => toast(`${count} deleted`)
})useBulkMutate
Run a mutation against multiple rows, collecting results and calling a callback when all settle:
const bulk = useBulkMutate(removeTask, {
onSuccess: () => setSelected(new Set()),
toast: {
loading: p => `Deleting: ${p.succeeded + p.failed}/${p.total}`,
success: count => `${count} task(s) deleted`
}
})
bulk.run([{ id: 1 }, { id: 2 }, { id: 3 }])useCacheEntry
Manages a single cache entry with automatic stale detection and refresh:
const { data, isLoading, isStale, refresh } = useCacheEntry({
args: { tmdbId: movieId },
data: cachedMovie,
load: async args => { await loadMovie(args) },
table: 'movie'
})useOptimisticMutation
Low-level optimistic mutation hook with rollback support:
const { execute, isPending, error } = useOptimisticMutation({
mutate: updatePost,
onOptimistic: args => { /* apply optimistic update */ },
onRollback: (args, err) => { /* revert on failure */ },
onSuccess: (result, args) => { /* called when mutation resolves */ },
onSettled: (args, error, result) => { setSubmitting(false) }
})useOnlineStatus
Tracks browser online/offline state:
const isOnline = useOnlineStatus()useForm
Form state management integrating Zod schemas with TanStack Form. Provides auto-derived field labels, conflict detection, auto-save, and typed field errors:
const form = useForm({
schema: blogSchema,
values: existingData,
onSubmit: async data => {
await save(data)
return data
}
})useFormMutation
Combines useForm with a mutation function. Handles loading state, error toasts, and field validation automatically:
import { useFormMutation } from 'noboil/convex/react'
const form = useFormMutation({
schema: blogSchema,
mutate: api.blogs.create,
toast: { success: 'Created', error: 'Failed' }
})import { useFormMutation } from 'noboil/spacetimedb/react'
const form = useFormMutation({
mutate: useReducer(reducers.createWiki),
toast: { success: 'Created' },
onSuccess: () => router.push('/wiki'),
schema: wiki,
transform: d => ({ ...d, orgId: Number(org._id) })
})useMutate
Wraps a mutation function with optimistic updates, devtools tracking, and toast errors:
const save = useMutate(updatePost, {
onSuccess: (result, args) => router.push(`/posts/${args.id}`),
onSettled: (args, error, result) => setSubmitting(false),
retry: 3,
toast: { success: 'Saved', error: 'Save failed' }
})
await save({ id: 1, title: 'Updated' })retry with full options:
const save = useMutate(updatePost, {
retry: {
maxAttempts: 5,
initialDelayMs: 200,
maxDelayMs: 5_000,
base: 2
}
})Migrating from older code: replace relax(...) with explicit input mapping before calling save.
const save = useMutate(updatePost)
await save({
id: Number(form.id),
title: String(form.title)
})useMutation (SpacetimeDB only)
Use useMutation from convex/react directly for raw Convex mutations.
Combines useReducer + useMutate into a single call:
import { useMutation } from 'noboil/spacetimedb/react'
import { useReducer } from 'spacetimedb/react'
import { reducers } from '@/generated/module_bindings'
const save = useMutation(useReducer, reducers.update_blog, {
toast: { success: 'Saved', error: 'Save failed' }
})
save({ id: 1, title: 'New title' })For the shortest form, useMut skips passing useReducer entirely:
import { useMut } from 'noboil/spacetimedb/react'
const save = useMut(reducers.update_blog, {
onSuccess: () => toast.success('Saved')
})Org hooks
useOrgQuery
Wraps a query and automatically injects orgId from the current org context:
import { useOrgQuery } from 'noboil/convex/react'
const projects = useOrgQuery(api.project.read, { status: 'active' })
const skipped = useOrgQuery(api.project.read, 'skip')useOrgMutation
Wraps a mutation and automatically injects orgId:
import { useOrgMutation } from 'noboil/convex/react'
const remove = useOrgMutation(api.project.rm)
await remove({ id: projectId })useOrgQuery
Wraps a query function and automatically injects orgId from the current org context:
import { useOrgQuery } from 'noboil/spacetimedb/react'
const projects = useOrgQuery(queryProject, { status: 'active' })
const skipped = useOrgQuery(queryProject, 'skip')useOrgMutation
Wraps a reducer and automatically injects orgId:
import { useOrgMutation } from 'noboil/spacetimedb/react'
const createProject = useOrgMutation(useReducer(reducers.create_project))
await createProject({ name: 'New project' })createOrgHooks
Factory that returns typed org hooks bound to a specific org type:
import { createOrgHooks } from 'noboil/convex/react'
const { useActiveOrg, useMyOrgs, useOrg, useOrgMutation } = createOrgHooks<MyOrg>()import { createOrgHooks } from 'noboil/spacetimedb/react'
const { useActiveOrg, useMyOrgs, useOrg, useOrgMutation } = createOrgHooks<
Org & { _id: string }
>({
orgIdForMutation: Number
})
const update = useOrgMutation(useReducer(reducers.orgUpdate))
await update(d) // orgId auto-injected as Number(org._id)Error handling
Error codes
The full list below is auto-generated by scanning every err('CODE') / throw new XError('CODE') / new ConvexError({ code: 'CODE' }) / throwConvexError('CODE') call across lib/noboil/src/{convex,spacetimedb,shared}/server. Adding a new error code in the source automatically appears here.
| Code | Meaning |
|---|---|
ALREADY_ORG_MEMBER | User is already a member of this org |
ALREADY_PROCESSED | Invite or join request was already accepted/declined |
CANNOT_MODIFY_ADMIN | Cannot demote or remove the last admin |
CANNOT_MODIFY_OWNER | Owner role cannot be reassigned |
CHUNK_ALREADY_UPLOADED | File chunk with this index was already uploaded in the session |
CHUNK_NOT_FOUND | File chunk index missing from upload session |
CONFLICT | Concurrent edit detected (expectedUpdatedAt mismatch) |
FILE_NOT_FOUND | Referenced file does not exist or was already deleted |
FILE_TOO_LARGE | File exceeds the configured size limit |
FORBIDDEN | Caller is authenticated but lacks permission |
INCOMPLETE_UPLOAD | Upload session finalized before all chunks arrived |
INSUFFICIENT_ORG_ROLE | Caller lacks the required org role for this action |
INVALID_FILE_TYPE | File MIME type is not in the allowlist |
INVALID_INVITE | Invite token is malformed, revoked, or unknown |
INVALID_KEY | Key is not in the kv table whitelist |
INVALID_SESSION_STATE | Upload session is in a state that disallows the requested action |
INVALID_WHERE | where clause references unknown field or invalid operator |
INVITE_EXPIRED | Invite token has expired |
JOIN_REQUEST_EXISTS | A join request from this user already exists |
LIMIT_EXCEEDED | Bulk operation exceeded the per-call item limit |
MUST_TRANSFER_OWNERSHIP | Owner cannot leave without transferring ownership |
NOT_AUTHENTICATED | No active session — caller must log in |
NOT_AUTHORIZED | Generic permission denial (not yet narrowed to a specific role) |
NOT_FOUND | Row doesn't exist OR caller doesn't own it (no-enumeration policy) |
NOT_ORG_MEMBER | Caller is not a member of the target org |
ORG_SLUG_TAKEN | Org slug is already in use |
RATE_LIMITED | Sliding-window rate limit exceeded |
SESSION_NOT_FOUND | Upload session id is unknown or expired |
TARGET_MUST_BE_ADMIN | Operation requires the target user to be an admin |
UNAUTHORIZED | Caller cannot perform this action (alias of NOT_AUTHORIZED in some surfaces) |
USER_NOT_FOUND | Referenced user identity does not exist |
VALIDATION_FAILED | Zod schema validation rejected the payload |
Handler example:
import { handleConvexError } from 'noboil/convex/server'
handleConvexError(error, {
NOT_AUTHENTICATED: () => router.push('/login'),
CONFLICT: () => toast.error('Someone else edited this'),
default: () => toast.error('Something went wrong')
})Rate limit metadata:
import { getErrorDetail } from 'noboil/convex/server'
handleConvexError(error, {
RATE_LIMITED: e => {
const detail = getErrorDetail(e)
if (detail?.retryAfter) {
toast.error(`Too many requests. Try again in ${Math.ceil(detail.retryAfter / 1000)}s`)
}
}
})Error boundary:
import { ErrorBoundary } from 'noboil/convex/components'
<ErrorBoundary
className="p-8"
fallback={({ error, reset }) => (
<div>
<p>Something went wrong</p>
<button onClick={reset}>Try again</button>
</div>
)}
>
<App />
</ErrorBoundary>36 structured error codes. Each maps to a descriptive user-facing message:
| Code | Description |
|---|---|
NOT_AUTHENTICATED | Please log in to continue |
NOT_FOUND | The requested resource could not be found |
FORBIDDEN | You do not have permission to perform this action |
CONFLICT | This record was modified by someone else — please review and try again |
RATE_LIMITED | Too many requests — please wait before trying again |
VALIDATION_FAILED | One or more fields failed validation — check your input |
INSUFFICIENT_ORG_ROLE | Insufficient permissions for this organization role |
ALREADY_ORG_MEMBER | Already a member of this organization |
INVITE_EXPIRED | Invite has expired |
INVALID_INVITE | Invalid invite |
ORG_SLUG_TAKEN | Organization slug already taken |
FILE_TOO_LARGE | File exceeds the maximum allowed size |
INVALID_FILE_TYPE | Invalid file type |
EDITOR_REQUIRED | Editor permission required |
Parse errors on the client:
import { extractErrorData, getErrorCode, getErrorMessage } from 'noboil/spacetimedb/server'
try {
await createPost(data)
} catch (error) {
const code = getErrorCode(error)
const message = getErrorMessage(error)
const detail = extractErrorData(error)
}Throw errors in reducers:
import { err, errValidation } from 'noboil/spacetimedb/server'
err('NOT_FOUND')
err('FORBIDDEN', 'User does not own this resource')
errValidation({ title: 'Required', slug: 'Already taken' })Error boundary:
import { ErrorBoundary } from 'noboil/spacetimedb/components'
<ErrorBoundary
className="p-8"
fallback={({ error, resetErrorBoundary }) => (
<div>
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
)}
>
<MyPage />
</ErrorBoundary>useErrorToast
Creates a stable, memoized callback that toasts errors with optional per-code handlers:
import { useErrorToast } from 'noboil/convex/react'
const handleError = useErrorToast({
toast: toast.error,
handlers: {
NOT_AUTHENTICATED: () => router.push('/login'),
CONFLICT: () => toast.error('Someone else edited this')
}
})import { useErrorToast } from 'noboil/spacetimedb/react'
const handleError = useErrorToast({
toast: toast.error,
handlers: {
NOT_AUTHENTICATED: () => router.push('/login'),
FORBIDDEN: () => toast.error('No permission')
}
})makeErrorHandler
Creates a reusable error handler with optional per-code overrides:
const handleError = makeErrorHandler(toast.error, {
FORBIDDEN: () => toast.error('You do not have permission'),
RATE_LIMITED: data =>
toast.error(`Too many requests. Retry in ${data?.retryAfter}ms`)
})defaultOnError
The default mutation error handler used by all mutation hooks. Handles NOT_AUTHENTICATED and RATE_LIMITED with user-friendly toasts, falls back to toasting the error message for all other codes.
React components
Devtools Panels
Development panel showing active subscriptions, pending mutations, and errors:
import { Devtools } from 'noboil/convex/react'
{process.env.NODE_ENV === 'development' && <Devtools />}import { Devtools } from 'noboil/spacetimedb/react'
{process.env.NODE_ENV === 'development' && <Devtools />}SchemaPlayground
Interactive schema explorer for development:
import { SchemaPlayground } from 'noboil/convex/react'
<SchemaPlayground className="my-8" />import { SchemaPlayground } from 'noboil/spacetimedb/react'
<SchemaPlayground tables={tables} />Form and field components
The <Form> component renders a typed form with automatic field layout:
import { Form, useForm } from 'noboil/convex/components'
// or: import { Form, useForm } from 'noboil/spacetimedb/components'
const form = useForm({ schema, values, onSubmit })
<Form form={form} render={f => (
<>
<f.Text name="title" />
<f.Toggle name="published" />
<f.Num name="rating" />
</>
)} />14 built-in field components: Text, Num, Toggle, Datepick, Timepick, Colorpick, Slider, Rating, Choose, Combobox, MultiSelect, File, Files, Arr. Each accepts the field name as a typed prop — misspelling a field name is a type error.
Shared field props:
| Prop | Type | Description |
|---|---|---|
disabled | boolean | Disables the input |
className | string | Style override for the component root |
SpacetimeDB field components additionally support:
| Prop | Type | Description |
|---|---|---|
helpText | string | Renders a hint below the field |
required | boolean | Appends a red * to the field label |
defineSteps
Creates a type-safe multi-step form with per-step Zod schemas:
import { defineSteps } from 'noboil/convex/components'
const { StepForm, useStepper } = defineSteps(
{ id: 'profile', label: 'Profile', schema: profileStep },
{ id: 'org', label: 'Organization', schema: orgStep }
)
<StepForm
stepIndicatorClassNames={{
button: 'size-9',
label: 'text-xs',
nav: 'gap-3',
separator: 'bg-primary/20',
step: 'gap-3'
}}
stepper={stepper}
/>import { defineSteps } from 'noboil/spacetimedb/components'
const { StepForm, useStepper, steps } = defineSteps([
{ id: 'profile', label: 'Profile', schema: profileSchema },
{ id: 'org', label: 'Organization', schema: orgSchema }
] as const)EditorsSection
Renders a card with the current editors list and a dropdown to add/remove editors:
<EditorsSection
contentClassName="rounded-md border"
emptyClassName="italic"
editorsList={editors}
headerClassName="pb-2"
itemClassName="px-2"
members={orgMembers}
onAdd={userId => addEditor({ id: resourceId, editorId: userId })}
onRemove={userId => removeEditor({ id: resourceId, editorId: userId })}
triggerClassName="w-48"
/>PermissionGuard
Conditionally renders children based on org role or a custom access check:
<PermissionGuard
role={membership.role}
allowedRoles={['owner', 'admin']}
resource="wiki page"
backHref="/wiki"
backLabel="wiki"
>
<EditForm />
</PermissionGuard>OfflineIndicator
Renders a fixed-position banner when the backend connection is inactive:
<OfflineIndicator className="z-50" />Server utilities
Middleware
import { auditLog, inputSanitize, slowQueryWarn } from 'noboil/convex/server'
const { crud } = setup({
// ...
middleware: [inputSanitize(), auditLog(), slowQueryWarn({ threshold: 200 })]
})import { noboil, auditLog, inputSanitize, slowQueryWarn } from 'noboil/spacetimedb/server'
export default noboil({
middleware: [inputSanitize(), auditLog(), slowQueryWarn({ threshold: 200 })],
tables: ({ table }) => ({ blog: table(s.blog, { pub: 'published' }) })
})| Middleware | Description |
|---|---|
inputSanitize(opts?) | Strips <script> tags and inline event handlers from string fields |
auditLog(opts?) | Logs structured audit entries for every create, update, and delete |
slowQueryWarn(opts?) | Emits a warning when any operation exceeds the time threshold |
Type utilities (SpacetimeDB)
import { pickValues } from 'noboil/convex/zod'
const values = pickValues(singleton.profile, profile)import type { InferRow, InferCreate, InferUpdate } from 'noboil/spacetimedb/server'
import { schemaVariants } from 'noboil/spacetimedb/zod'
import { partialValues } from 'noboil/spacetimedb/zod'
type PostRow = InferRow<typeof s.post>
type PostCreate = InferCreate<typeof s.post>
type PostUpdate = InferUpdate<typeof s.post>
const { create: createSchema, update: updateSchema } = schemaVariants(postSchema, ['title'])
update(partialValues(editSchema, { id, published: true }))Or use phantom type accessors (no import needed):
type PostRow = typeof s.post.$inferRow
type PostCreate = typeof s.post.$inferCreate
type PostUpdate = typeof s.post.$inferUpdateESLint rules
Both noboil/convex/eslint and noboil/spacetimedb/eslint ship the same rule set under different plugin names. Enable via recommended config or pick rules individually.
| Rule | Convex | SpacetimeDB | Message |
|---|---|---|---|
api-casing | ✓ | ✓ | api.{{used}} — wrong casing. Use api.{{suggestion}} to match the convex/ filename. |
consistent-crud-naming | ✓ | ✓ | Table name '{{got}}' doesn't match schema property '{{expected}}'. Use '{{expected}}' to avoid runtime errors. |
discovery-check | ✓ | ✓ | noboil/convex: could not find {{missing}} (searched ./convex/ and ./lib/*/convex/). Some rules are inactive. |
form-field-exists | ✓ | ✓ | '{{field}}' does not match any field in the schema. Check for typos. |
form-field-kind | ✓ | ✓ | '{{field}}' is a {{expected}} field, but rendered with <{{got}}>. Use <{{expected}}> instead. |
no-duplicate-crud | ✓ | ✓ | Duplicate CRUD factory for table '{{table}}'. Already registered in {{file}}. |
no-empty-search-config | ✓ | ✓ | search: {} is ambiguous. Specify the field to search: search: 'fieldName' or search: { field: 'fieldName' }. |
no-raw-fetch-in-server-component | ✓ | ✓ | {{fn}}() without try-catch. If the query fails, the page crashes. Wrap in try-catch or use an error-handling wrapper. |
no-unlimited-file-size | ✓ | ✓ | {{call}} without .max() in schema. Add a size limit to prevent unbounded file uploads. |
no-unprotected-mutation | ✓ | ✓ | m() handler without auth check. Call getAuthUserId() or add a comment explaining why auth is not needed. |
no-unsafe-api-cast | ✓ | ✓ | Unsafe cast on api object. This bypasses type safety. Extract the function reference from the factory or use a custom query. |
prefer-useList | ✓ | ✓ | useQuery() on a list endpoint — use useList() instead for built-in pagination, loadMore, and loading states. |
prefer-useOrgQuery | ✓ | ✓ | useQuery() with orgId — use useOrgQuery() instead. It injects orgId automatically from the OrgProvider context. |
require-connection | ✓ | ✓ | {{fn}}() requires 'await connection()' before it in Next.js server components to signal dynamic rendering. |
require-error-boundary | ✓ | ✓ | <ConvexProvider> without an error boundary. Wrap with an ErrorBoundary to handle Convex errors gracefully. |
require-rate-limit | ✓ | ✓ | {{factory}}() without rateLimit. Add rateLimit: { max, window } to prevent abuse on write endpoints. |
API stability
JSDoc-tagged exports are surfaced here so consumers can tell stable API from in-flux surface. The generator picks up @beta, @alpha, @experimental, @deprecated, @internal on the line above any export/const/function/class/interface/type declaration.
Scanned 359 files, 7646 declared symbols. No @beta/@alpha/@experimental/@deprecated/@internal JSDoc tags found — entire public surface is stable.
To mark API stability, add JSDoc tags above an export:
/** @beta New shape, may change without major version bump. */
export const someExperiment = () => 42This generator picks them up automatically.
Tooling helpers
These exports power the CLI and bundler integration. End-user code rarely calls them directly, but they are part of the public surface.
resolveAliasFor(condition)— given aNoboilCondition('noboil-convex'or'noboil-spacetimedb'), returns the bundler alias map used by Vite/webpack/turbopack to resolvenoboil/db/*imports to the right backend during build.NoboilCondition— type alias for the two supportedcustomConditionsstrings ('noboil-convex','noboil-spacetimedb').loadConfig(cwd)— async loader fornoboil.config.{ts,mts,js,mjs}from the given directory. Used internally bynoboil addto invokebeforeAdd/afterAddhooks. See Plugin hooks for the user-facing API.
Known limitations
- Where clauses use runtime filtering —
$gt,$lt,$between,oruse.filter(), not index lookups. Fine for fewer than 1,000 docs. For high-volume tables, usepubIndexed/authIndexedwith Convex indexes. PassstrictFilter: truetosetup()to throw instead of warn. - Search requires schema index setup — define
searchincrud(...)and add a matchingsearchIndexto the table schema. - Bulk operations cap at 100 items per call.
anyApiProxy accepts arbitrary property names at runtime — TypeScript won't flagapi.blogprofile(wrong casing) even if onlyapi.blogProfileexists. Typos in module paths silently construct invalid function references that crash at runtime. Rely on E2E tests and Convex deploy errors to catch these.
- Client-side filtering —
useListwhereclauses filter in the browser over subscription data. For large datasets, use server-sideWHEREsubscriptions viauseTable(tables.post.where(...)). - No built-in rate limiting — implement manually with a tracking table.
ctx.http.fetch()panics in local Docker — use Next.js API routes for external HTTP calls.- Optimistic updates — not needed at ~39ms local latency;
useOptimisticMutationis available for cases where you need it. - Full OAuth — requires Maincloud; local dev uses anonymous identity.
Per-table endpoint inventory
Each module in backend/convex/convex/ re-exports the factory-generated CRUD plus any custom pq/q/m builders defined alongside. This is the runtime surface clients call into.
119 endpoints across 10 table modules in backend/convex/convex/. Each row is a re-export aggregator combining factory-generated CRUD + custom pq/q/m builders.
| Module | Count | Endpoints |
|---|---|---|
blog | 9 | authorPosts, create, list, postStats, read, rm, search, togglePublish, update |
chat | 6 | create, list, pubRead, read, rm, update |
org | 25 | acceptInvite, approveJoinRequest, cancelJoinRequest, create, get, getBySlug, getOrCreate, getPublic, invite, isSlugAvailable, leave, members, membership, myJoinRequest, myOrgs, pendingInvites, pendingJoinRequests, rejectJoinRequest, remove, removeMember, requestJoin, revokeInvite, setAdmin, transferOwnership, update |
poll | 5 | create, list, read, rm, update |
pollVoteQuota | 3 | check, consume, record |
siteConfig | 5 | get, list, restore, rm, set |
task | 8 | assign, byProject, create, list, read, rm, toggle, update |
testauth | 47 | TEST_EMAIL, acceptInviteAsUser, addEditorAsUser, addTestOrgMember, addWikiEditorAsUser, approveJoinRequestAsUser, assignTaskAsUser, cancelJoinRequestAsUser, cleanupOrgTestData, cleanupTestData, cleanupTestUsers, createExpiredInvite, createProjectAsUser, createTaskAsUser, createTestUser, createWikiAsUser, deleteOrgAsUser, deleteProjectAsUser, deleteWikiAsUser, ensureTestUser, getAuthUserIdOrTest, getJoinRequest, getProjectEditors, getTestUser, getTestUserByEmail, inviteAsUser, isTestMode, leaveOrgAsUser, listProjectsAsUser, pendingInvitesAsUser, pendingJoinRequestsAsUser, rejectJoinRequestAsUser, removeEditorAsUser, removeMemberAsUser, removeTestOrgMember, removeWikiEditorAsUser, requestJoinAsUser, rmTaskAsUser, setAdminAsUser, toggleTaskAsEditorUser, toggleTaskAsUser, transferOwnershipAsUser, updateOrgAsUser, updateProjectAsEditorUser, updateProjectAsUser, updateTaskAsUser, updateWikiAsUser |
user | 1 | me |
vote | 10 | append, auth, authIndexed, list, listAfter, purgeByParent, read, restoreByParent, rm, update |
Package subpath exports
Every entry in noboil/package.json#exports. Use these exact paths in import statements — bundlers resolve via the customConditions setting in your tsconfig.
44 subpath exports from noboil package.json. Use exact import paths for tree-shaking.
| Import path | Runtime entry | Types entry |
|---|---|---|
noboil | ./src/noboil.ts | — |
noboil/ansi | ./src/ansi.ts | — |
noboil/components | ./src/convex/components/index.ts | `` |
noboil/config | ./src/config.ts | — |
noboil/convex | ./src/convex/index.ts | — |
noboil/convex/components | ./src/convex/components/index.ts | — |
noboil/convex/eslint | ./src/convex/eslint.ts | — |
noboil/convex/next | ./src/convex/next/index.ts | — |
noboil/convex/react | ./src/convex/react/index.ts | — |
noboil/convex/retry | ./src/convex/retry.ts | — |
noboil/convex/schema | ./src/convex/schema.ts | — |
noboil/convex/seed | ./src/convex/seed.ts | — |
noboil/convex/server | ./src/convex/server/index.ts | — |
noboil/convex/test | ./src/convex/server/test.ts | — |
noboil/convex/test/discover | ./src/convex/server/test-discover.ts | — |
noboil/convex/tools | ./src/convex/tools/index.ts | — |
noboil/convex/tools/codegen | ./src/convex/tools/codegen/index.ts | — |
noboil/convex/zod | ./src/convex/zod.ts | — |
noboil/env-file | ./src/shared/env-file.ts | — |
noboil/eslint | ./src/convex/eslint.ts | `` |
noboil/next | ./src/convex/next/index.ts | `` |
noboil/package.json | ./package.json | — |
noboil/react | ./src/convex/react/index.ts | `` |
noboil/retry | ./src/convex/retry.ts | `` |
noboil/schema | ./src/convex/schema.ts | `` |
noboil/seed | ./src/convex/seed.ts | `` |
noboil/server | ./src/convex/server/index.ts | `` |
noboil/spacetimedb | ./src/spacetimedb/index.ts | — |
noboil/spacetimedb/components | ./src/spacetimedb/components/index.ts | — |
noboil/spacetimedb/eslint | ./src/spacetimedb/eslint.ts | — |
noboil/spacetimedb/next | ./src/spacetimedb/next/index.ts | — |
noboil/spacetimedb/react | ./src/spacetimedb/react/index.ts | — |
noboil/spacetimedb/retry | ./src/spacetimedb/retry.ts | — |
noboil/spacetimedb/schema | ./src/spacetimedb/schema.ts | — |
noboil/spacetimedb/seed | ./src/spacetimedb/seed.ts | — |
noboil/spacetimedb/server | ./src/spacetimedb/server/index.ts | — |
noboil/spacetimedb/test | ./src/spacetimedb/server/test.ts | — |
noboil/spacetimedb/test/discover | ./src/spacetimedb/server/test-discover.ts | — |
noboil/spacetimedb/zod | ./src/spacetimedb/zod.ts | — |
noboil/test | ./src/convex/server/test.ts | `` |
noboil/test/discover | ./src/convex/server/test-discover.ts | `` |
noboil/test/utils | ./src/shared/test/index.ts | — |
noboil/walk | ./src/shared/walk.ts | — |
noboil/zod | ./src/convex/zod.ts | `` |
Convex HTTP routes
Every explicit http.route(...) registration in backend/convex/convex/http.ts. Auth-related routes are added by auth.addHttpRoutes(http) and are not enumerated here.
2 explicit http.route(...) registrations in backend/convex/convex/http.ts (plus auth routes added by auth.addHttpRoutes(http) not enumerated here).
| Method | Path |
|---|---|
OPTIONS | /api/auth/signin |
POST | /api/auth/signin |
Public symbol → doc coverage
How many of noboil's public exports are mentioned anywhere in the docs. Anything in the "undocumented" list is callable but never described — use as a punch list.
Coverage of public exports (every name reachable through noboil/... subpaths) by mention in doc/content/docs/*.mdx. 514/514 mentioned (100%).
Undocumented (first 0 of 0):
(none — full coverage)