Gradual adoption
Use noboil factories where they fit, raw Convex/SpacetimeDB where they don't. The two coexist in the same project.
noboil is not all-or-nothing. Every table in a noboil project can be either factory-generated or hand-written. Every mutation can be generated, composed, or fully custom. This guide shows the mixing patterns so you can adopt incrementally.
The default: factory-generated
When a table maps cleanly to a CRUD pattern (owned, org-scoped, singleton, cache, child, log, kv, quota), register it via noboil({tables: ({table}) => ({...})}) and let factories generate the endpoints.
// convex/chat.ts
import { api } from '../lazy'
const {
auth: { list, read },
create,
rm,
update
} = api.chat
export { create, list, read, rm, update }Client:
import { useCrud } from 'noboil/convex/react'
const chats = useCrud(api.chat)Mix 1: hand-written mutations alongside factory endpoints
The table is in s.ts + schema.ts and registered in lazy.ts. But one specific mutation needs custom logic. Write it raw and export from the same module:
// convex/chat.ts
import { mutation } from './_generated/server'
import { v } from 'convex/values'
import { api } from '../lazy'
const {
auth: { list, read },
create,
rm,
update
} = api.chat
// custom — factory doesn't cover this
export const markArchived = mutation({
args: { id: v.id('chat') },
handler: async (ctx, { id }) => {
const chat = await ctx.db.get(id)
if (!chat) throw new Error('not found')
await ctx.db.patch(id, { archivedAt: Date.now() })
}
})
export { create, list, markArchived, read, rm, update }Client uses the same api.chat.markArchived(...) as factory endpoints. No type gymnastics.
Mix 2: table that doesn't fit any factory
Some tables are legitimately outside CRUD — event sourcing with complex deduplication, time-series with retention, analytics rollups. Define them raw in convex/schema.ts and write mutations by hand:
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
import { ownedTable } from 'noboil/convex/server'
import { owned } from '../s'
export default defineSchema({
...({
blog: ownedTable(owned.blog), // factory
chat: ownedTable(owned.chat) // factory
}),
analytics: defineTable({ // hand-written
event: v.string(),
userId: v.optional(v.id('users')),
props: v.any(),
ts: v.number()
})
.index('by_event_ts', ['event', 'ts'])
.index('by_user_ts', ['userId', 'ts'])
})// convex/analytics.ts — fully raw, no noboil
import { mutation, query } from './_generated/server'
import { v } from 'convex/values'
export const track = mutation({
args: { event: v.string(), props: v.any() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
await ctx.db.insert('analytics', { ...args, ts: Date.now(), userId: userId ?? undefined })
}
})
export const eventCount = query({
args: { event: v.string(), since: v.number() },
handler: async (ctx, { event, since }) =>
(await ctx.db.query('analytics').withIndex('by_event_ts', q => q.eq('event', event).gt('ts', since)).collect()).length
})Client calls api.analytics.track(...) identically to factory-generated endpoints.
Mix 3: HTTP actions
noboil factories only generate RPC mutations/queries. HTTP endpoints (webhooks, OAuth callbacks, Stripe events) use Convex's httpAction as-is:
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
const http = httpRouter()
http.route({
path: '/stripe',
method: 'POST',
handler: httpAction(async (ctx, request) => {
const body = await request.json()
await ctx.runMutation(api.subscription.update, { ... }) // calls factory-generated mutation
return new Response(null, { status: 200 })
})
})
export default httpHTTP actions coexist with noboil — they just call factory-generated mutations via ctx.runMutation.
Mix 4: scheduled functions
Convex's scheduler runs background jobs. Factory-generated mutations are callable from scheduled handlers unchanged:
// convex/crons.ts
import { cronJobs } from 'convex/server'
import { internal } from './_generated/api'
const crons = cronJobs()
crons.interval('prune old logs', { hours: 24 }, internal.message.purgeByParent, { parent: someChatId })
export default cronsThe _scheduled_functions system table is referenced in your schema via v.id('_scheduled_functions') — noboil doesn't interfere.
Mix 5: custom Zod refinements on factory tables
You want a table to be factory-generated but with a field-level refinement Zod supports and noboil doesn't auto-enforce:
// s.ts
const owned = {
blog: object({
title: string().min(1).max(200).refine(s => !/[<>]/u.test(s), 'no HTML'),
content: string().min(3),
// ... factory still generates create/update that validate via this schema
})
}The refinement runs in the factory's generated create/update mutations automatically — you don't need to intercept.
What NOT to mix
- Don't patch factory-generated endpoints — if you need different behavior, write a sibling mutation and stop using the generated one for that operation. Patching leads to drift.
- Don't bypass the schema — if you insert raw via
ctx.db.inserton a factory-registered table, you skip the Zod validation the factory enforces. Use the factory'screatemutation, or don't register the table. - Don't mix DBs per table — a table lives in Convex or SpacetimeDB, not both. noboil's
s.tsis one schema per project.
Migration path from raw Convex
Going the other way: you have an existing Convex project, want to adopt noboil for new tables without rewriting old ones.
- Add
noboilas a dependency (you already have since you're reading this) - Keep existing
schema.tsas-is; add a news.tswith Zod schemas for NEW tables only - Add
noboil()setup inlazy.tsfor the new tables - In
convex/schema.ts, spread both raw defineTable calls andownedTable(owned.x)etc in the samedefineSchema - Old tables keep their hand-written
convex/oldTable.tsmodules; new tables get re-export modules fromapi.x
No migration of existing code required. You can coexist forever.