noboil

API Reference

Complete API reference for noboil/convex and noboil/spacetimedb — server factories, React hooks, utilities, and error codes.

Installation

bun add noboil

Peer dependencies: convex, convex-helpers, zod, @tanstack/react-form, react.

bun add noboil

Peer dependencies: spacetimedb, zod, @tanstack/react-form, react.

Imports

ModuleKey Exports
noboil/convexACTIVE_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/serverAuditAppendInput, 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/zodDefType, FileKind, ZodSchema, coerceOptionals, defaultValue, defaultValues, elementOf, enumToOptions, fileKindOf, fileMetaOf, isArrayType, isBooleanType, isDateType, isNumberType, isOptionalField, isStringType, pickValues, requiredPartial, unwrapZod
noboil/convex/retryRetryOptions, fetchWithRetry, withRetry
noboil/convex/schemachild, file, files, makeBase, makeKv, makeLog, makeOrg, makeOrgScoped, makeOwned, makeQuota, makeSingleton, orgSchema, schema
noboil/convex/reactApi, 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/componentsApi, AutoForm, AutoSaveIndicator, ConflictDialog, EditorsSection, ErrorBoundary, FileApiContext, FileApiProvider, FileFieldImpl, Form, FormContext, OfflineIndicator, OrgAvatar, PermissionGuard, RoleBadge, ServerFieldError, defineSteps, deriveLabel, fields, useForm, useFormMutation
noboil/convex/eslintplugin, recommended, rules
noboil/convex/nextclearActiveOrgCookie, getActiveOrg, getToken, isAuthenticated, makeImageRoute, setActiveOrgCookie
noboil/convex/testOrgTestCrudConfig, TEST_EMAIL, TestAuthConfig, TestUser, createTestContext, getOrgMembership, isTestMode, makeOrgTestCrud, makeTestAuth
noboil/convex/test/discoverdiscoverModules
noboil/convex/toolsActionCtxExtras, 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/codegenExtracted, SchemaNode, ToolFile, collect, emitRegistry, emitToolCallers, emitToolTypes, extractSchemas
noboil/convex/seedgenerateFieldValue, generateOne, generateSeed
ModuleKey Exports
noboil/spacetimedbACTIVE_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/serverCHUNK_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/zodDefType, FileKind, UndefinedToOptional, ZodSchema, coerceOptionals, defaultValue, defaultValues, elementOf, enumToOptions, fileKindOf, fileMetaOf, isArrayType, isBooleanType, isDateType, isNumberType, isOptionalField, isStringType, partialValues, pickValues, requiredPartial, schemaVariants, unwrapZod
noboil/spacetimedb/retryRetryOptions, fetchWithRetry, withRetry
noboil/spacetimedb/schemachild, file, files, makeBase, makeKv, makeLog, makeOrg, makeOrgScoped, makeOwned, makeQuota, makeSingleton, orgSchema, schema
noboil/spacetimedb/reactActiveOrgState, 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/componentsApi, AutoForm, AutoSaveIndicator, ConflictDialog, EditorsSection, ErrorBoundary, FileApiContext, FileApiProvider, FileFieldImpl, Form, FormContext, OfflineIndicator, OrgAvatar, PermissionGuard, RoleBadge, ServerFieldError, UploadOptions, UploadResponse, defineSteps, deriveLabel, fields, useForm, useFormMutation
noboil/spacetimedb/eslintplugin, recommended, rules
noboil/spacetimedb/nextActiveOrgQuery, SqlQueryConfig, TableQueryConfig, clearActiveOrgCookie, getActiveOrg, getToken, isAuthenticated, makeImageRoute, queryTable, setActiveOrgCookie
noboil/spacetimedb/testErrorData, TestContext, TestUser, asUser, callReducer, cleanup, createTestContext, createTestUser, extractErrorData, getErrorCode, getErrorDetail, getErrorMessage, isTestMode, queryTable
noboil/spacetimedb/test/discoverdiscoverModules
noboil/spacetimedb/seedgenerateFieldValue, 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.setup

setup()

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.vote

kv() — 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.siteConfig

quota() — 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.pollVoteQuota

noboil()

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 typeAvailable options
ownedindex, unique, extra, softDelete, rateLimit, pub
orgScopedindex, unique, extra, softDelete, rateLimit, cascade, indexes, pub
orgindex, unique, extra
base/cachekey, ttl
singletonnone
childnone
logsoftDelete, rateLimit, pub (and per-row pub: { parentField }), hooks, withAuthor
kvsoftDelete, rateLimit, hooks, cleanFiles
quotahooks (beforeConsume/afterConsume/beforeRecord/afterRecord/onExceeded)

Generated reducers for owned tables:

ReducerParametersDescription
create_{tableName}All fieldsInsert a row. Sets userId = ctx.sender, updatedAt = ctx.timestamp.
update_{tableName}id, optional fields, optional expectedUpdatedAtUpdate a row. Caller must be the owner. Throws CONFLICT if expectedUpdatedAt doesn't match.
rm_{tableName}idDelete 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 visible

Factory options (auto-derived from LogOptions/KvOptions interfaces in source):

log

OptionTypeRequired
hooksLogHooks<DB>no
rateLimitRateLimitConfigno
softDeletebooleanno

kv

OptionTypeRequired
hooksKvHooks<DB>no
rateLimitRateLimitConfigno
softDeletebooleanno

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):

FactoryConvex-generated endpoints
logappend (mutation), authIndexed (query), authList (query), listAfter (query), purgeByParent (mutation), read (query), rmOne (mutation), update (mutation)
kvget (query), list (query), rm (mutation), set (mutation)
quotacheck (query), consume (mutation), record (mutation)

Generated reducers for log/kv/quota tables (auto-derived from lib/noboil/src/spacetimedb/server/{log,kv,quota}.ts):

FactoryReducerParametersDescription
logappend_{table}parent, ...payload, optional idempotency_keyAtomic seq + insert. Idempotent if idempotency_key provided.
bulk_append_{table}parent, items[]Single-transaction batch insert.
purge_{table}_by_parentparentSoft- or hard-delete all rows for a parent.
restore_{table}_by_parentparentBring back soft-deleted rows for a parent. Requires softDelete: true.
rm_{table}idHard-delete a single row. Author-only.
bulk_rm_{table}ids[]Hard-delete multiple rows. Author-only.
update_{table}id, ...payload, optional expectedUpdatedAtUpdate a row's payload. Preserves seq + parent. Author-only. Throws CONFLICT on stale expectedUpdatedAt.
kvset_{table}key, ...payload, optional expectedUpdatedAtUpsert with optional conflict check. Throws CONFLICT on stale expectedUpdatedAt.
rm_{table}keySoft- or hard-delete by key.
restore_{table}keyBring back a soft-deleted key. Requires softDelete: true.
quotaconsume_{table}ownerAtomic check + record. Throws when over limit.
record_{table}ownerAppend 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'
})
ReducerParametersDescription
presence_heartbeat_{tableName}roomId, optional dataUpsert presence row.
presence_leave_{tableName}roomIdDelete presence row for the caller.
presence_cleanup_{tableName}noneDelete 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:

HookConvexSpacetimeDB
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 } } }) // OK

Return value:

FieldTypeDescription
dataT[]Current page of filtered, sorted rows
hasMorebooleanWhether more rows exist
isLoadingbooleantrue until isReady is true
loadMore() => voidLoad the next page
pagenumberCurrent page number
totalCountnumberTotal 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):

HookParams
useBulkMutate(mutate: (args: A) =&gt; Promise&lt;R&gt;, options?: UseBulkMutateOptions)
useBulkSelection(options: UseBulkSelectionOpts)
useCacheEntry(\{ args, get: getRef, load: loadRef \}: UseCacheEntryOptions&lt;Q, A&gt;)
useCrud(refs: R, options?: CrudOptions)
useInfiniteList(query: F, ...rest: InfiniteListRest&lt;F&gt;)
useKv(refs: R, key: string)
useList(query: F, ...rest: ListRest&lt;F&gt;)
useLog(refs: R, args: \{ parent: string \})
useMutate(ref: T, options?: MutateOptions)
useOptimisticMutation(\{ mutation, onOptimistic, onRollback, onSettled, onSuccess \}: OptimisticOptions&lt;T&gt;)
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) =&gt; OptionalRestArgs&lt;F&gt;[0], options?: UseSearchOptions)
useSingleton(refs: R)
useSoftDelete(options: SoftDeleteOpts&lt;A&gt;)
useUpload(uploadMutation: FunctionReference&lt;'mutation'&gt;, options?: UploadOptions)

14 hooks:

HookParams
useBulkMutate(mutate: (args: A) =&gt; Promise&lt;R&gt;, 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) =&gt; Promise&lt;void&gt;, options?: UsePresenceOptions)
useQuota(refs: StdbQuotaRefs, owner: string)
useSearch`(data: T[], isReady: boolean, options: 'skip' \
useSingleton`(refs: StdbSingletonRefs, sender: Identity \
useSoftDelete(options: SoftDeleteOpts&lt;A&gt;)
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.

CodeMeaning
ALREADY_ORG_MEMBERUser is already a member of this org
ALREADY_PROCESSEDInvite or join request was already accepted/declined
CANNOT_MODIFY_ADMINCannot demote or remove the last admin
CANNOT_MODIFY_OWNEROwner role cannot be reassigned
CHUNK_ALREADY_UPLOADEDFile chunk with this index was already uploaded in the session
CHUNK_NOT_FOUNDFile chunk index missing from upload session
CONFLICTConcurrent edit detected (expectedUpdatedAt mismatch)
FILE_NOT_FOUNDReferenced file does not exist or was already deleted
FILE_TOO_LARGEFile exceeds the configured size limit
FORBIDDENCaller is authenticated but lacks permission
INCOMPLETE_UPLOADUpload session finalized before all chunks arrived
INSUFFICIENT_ORG_ROLECaller lacks the required org role for this action
INVALID_FILE_TYPEFile MIME type is not in the allowlist
INVALID_INVITEInvite token is malformed, revoked, or unknown
INVALID_KEYKey is not in the kv table whitelist
INVALID_SESSION_STATEUpload session is in a state that disallows the requested action
INVALID_WHEREwhere clause references unknown field or invalid operator
INVITE_EXPIREDInvite token has expired
JOIN_REQUEST_EXISTSA join request from this user already exists
LIMIT_EXCEEDEDBulk operation exceeded the per-call item limit
MUST_TRANSFER_OWNERSHIPOwner cannot leave without transferring ownership
NOT_AUTHENTICATEDNo active session — caller must log in
NOT_AUTHORIZEDGeneric permission denial (not yet narrowed to a specific role)
NOT_FOUNDRow doesn't exist OR caller doesn't own it (no-enumeration policy)
NOT_ORG_MEMBERCaller is not a member of the target org
ORG_SLUG_TAKENOrg slug is already in use
RATE_LIMITEDSliding-window rate limit exceeded
SESSION_NOT_FOUNDUpload session id is unknown or expired
TARGET_MUST_BE_ADMINOperation requires the target user to be an admin
UNAUTHORIZEDCaller cannot perform this action (alias of NOT_AUTHORIZED in some surfaces)
USER_NOT_FOUNDReferenced user identity does not exist
VALIDATION_FAILEDZod 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:

CodeDescription
NOT_AUTHENTICATEDPlease log in to continue
NOT_FOUNDThe requested resource could not be found
FORBIDDENYou do not have permission to perform this action
CONFLICTThis record was modified by someone else — please review and try again
RATE_LIMITEDToo many requests — please wait before trying again
VALIDATION_FAILEDOne or more fields failed validation — check your input
INSUFFICIENT_ORG_ROLEInsufficient permissions for this organization role
ALREADY_ORG_MEMBERAlready a member of this organization
INVITE_EXPIREDInvite has expired
INVALID_INVITEInvalid invite
ORG_SLUG_TAKENOrganization slug already taken
FILE_TOO_LARGEFile exceeds the maximum allowed size
INVALID_FILE_TYPEInvalid file type
EDITOR_REQUIREDEditor 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:

PropTypeDescription
disabledbooleanDisables the input
classNamestringStyle override for the component root

SpacetimeDB field components additionally support:

PropTypeDescription
helpTextstringRenders a hint below the field
requiredbooleanAppends 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' }) })
})
MiddlewareDescription
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.$inferUpdate

ESLint 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.

RuleConvexSpacetimeDBMessage
api-casingapi.{{used}} — wrong casing. Use api.{{suggestion}} to match the convex/ filename.
consistent-crud-namingTable name '{{got}}' doesn't match schema property '{{expected}}'. Use '{{expected}}' to avoid runtime errors.
discovery-checknoboil/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-crudDuplicate CRUD factory for table '{{table}}'. Already registered in {{file}}.
no-empty-search-configsearch: {} 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-mutationm() handler without auth check. Call getAuthUserId() or add a comment explaining why auth is not needed.
no-unsafe-api-castUnsafe cast on api object. This bypasses type safety. Extract the function reference from the factory or use a custom query.
prefer-useListuseQuery() on a list endpoint — use useList() instead for built-in pagination, loadMore, and loading states.
prefer-useOrgQueryuseQuery() 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 = () => 42

This 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 a NoboilCondition ('noboil-convex' or 'noboil-spacetimedb'), returns the bundler alias map used by Vite/webpack/turbopack to resolve noboil/db/* imports to the right backend during build.
  • NoboilCondition — type alias for the two supported customConditions strings ('noboil-convex', 'noboil-spacetimedb').
  • loadConfig(cwd) — async loader for noboil.config.{ts,mts,js,mjs} from the given directory. Used internally by noboil add to invoke beforeAdd/afterAdd hooks. See Plugin hooks for the user-facing API.

Known limitations

  • Where clauses use runtime filtering$gt, $lt, $between, or use .filter(), not index lookups. Fine for fewer than 1,000 docs. For high-volume tables, use pubIndexed/authIndexed with Convex indexes. Pass strictFilter: true to setup() to throw instead of warn.
  • Search requires schema index setup — define search in crud(...) and add a matching searchIndex to the table schema.
  • Bulk operations cap at 100 items per call.
  • anyApi Proxy accepts arbitrary property names at runtime — TypeScript won't flag api.blogprofile (wrong casing) even if only api.blogProfile exists. 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 filteringuseList where clauses filter in the browser over subscription data. For large datasets, use server-side WHERE subscriptions via useTable(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; useOptimisticMutation is 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.

ModuleCountEndpoints
blog9authorPosts, create, list, postStats, read, rm, search, togglePublish, update
chat6create, list, pubRead, read, rm, update
org25acceptInvite, approveJoinRequest, cancelJoinRequest, create, get, getBySlug, getOrCreate, getPublic, invite, isSlugAvailable, leave, members, membership, myJoinRequest, myOrgs, pendingInvites, pendingJoinRequests, rejectJoinRequest, remove, removeMember, requestJoin, revokeInvite, setAdmin, transferOwnership, update
poll5create, list, read, rm, update
pollVoteQuota3check, consume, record
siteConfig5get, list, restore, rm, set
task8assign, byProject, create, list, read, rm, toggle, update
testauth47TEST_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
user1me
vote10append, 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 pathRuntime entryTypes 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).

MethodPath
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)

On this page