noboil

owned factory (user-owned CRUD)

User-scoped CRUD with full create/list/read/update/rm — the default factory for "each row belongs to one user". For posts, chats, todos, drafts.

Use owned for any table where each row has one owner — blog posts, chats, todos, drafts, personal collections. The factory injects userId automatically, scopes every read to the caller's rows, and rejects writes to rows the caller doesn't own. This is the most-used factory; reach for it before considering anything else.

API surface

type CrudResult = {
  create: Mutation<(ctx, payload: T) => Id>
  update: Mutation<(ctx, { id, ...patch }: { id } & Partial<T> & { expectedUpdatedAt? }) => Row>
  rm: Mutation<(ctx, { id }) => { deleted: boolean }>
  pub: { list, read, search }   // public reads (when pub: true)
  auth: { list, read, search }  // ownership-checked reads
}
MethodAuthSemantics
create(payload)requiredInserts row with userId = ctx.user._id, updatedAt = now(). Returns id.
update({id, ...patch})requiredPatches id (must be owned). Optional expectedUpdatedAt for conflict detection.
rm({id})requiredHard-delete (or soft-delete with softDelete: true).
auth.list({paginationOpts})requiredCursor-paginated; only the caller's rows.
auth.read({id})requiredReturns row if owned, throws NOT_FOUND otherwise (no enumeration leak).
auth.search({query})requiredFull-text search over a configured field; caller's rows only.
pub.*publicSame surface but without ownership filter — set pub: true or pub: 'fieldName'.
const { tables, reducers } = makeCrud(spacetimedb, {
  tableName: 'post',
  fields,
  idField: 'id',
  pk,
  table
})

Generated reducers per table post:

ReducerAuthParamsSemantics
create_postrequired...payloadInserts row with userId = ctx.sender.
update_postrequiredid, ...patch, expectedUpdatedAt?Author-only. Throws CONFLICT on stale expectedUpdatedAt.
rm_postrequiredidHard-delete (or soft-delete with softDelete: true).
bulk_rm_postrequiredids[]Batch delete, single transaction.

Subscriptions are real-time over tables.post — there is no list/read reducer; the client iterates the subscribed table directly.

Schema

import { object, string, boolean, array } from 'zod/v4'
import { schema, makeOwned } from 'noboil/convex/schema'

const owned = makeOwned({
  blog: object({
    title: string().min(1),
    content: string(),
    published: boolean(),
    tags: array(string()).max(5).optional()
  }),
  todo: object({
    text: string().min(1),
    done: boolean()
  })
})
import { object, string, boolean, array } from 'zod/v4'
import { schema, makeOwned } from 'noboil/spacetimedb/schema'

const owned = makeOwned({
  blog: object({
    title: string().min(1),
    content: string(),
    published: boolean(),
    tags: array(string()).max(5).optional()
  })
})

Auto-fields injected:

  • userId — caller identity at insert time
  • updatedAt — last-write timestamp (used for conflict detection)
  • _creationTime (Convex) / createdAt (SpacetimeDB) — insert timestamp
  • deletedAt? — set when softDelete: true and row is removed

Index auto-created: by_user.

Wire it up

// backend/convex/lazy.ts
const api = noboil({
  /* ... */
  tables: ({ table }) => ({
    blog: table(s.blog, {
      pub: 'published',                       // pub.list/read sees only published rows
      rateLimit: { max: 10, window: 60_000 }, // 10 writes/min/user
      search: 'content',                      // enables auth.search by 'content' field
      softDelete: true                        // rm soft-deletes; restore endpoint generated
    })
  })
})
// backend/convex/convex/blog.ts
import { api } from '../lazy'
export const { create, update, rm, pub: { list, read, search } } = api.blog
// backend/spacetimedb/src/index.ts
const spacetimedb = noboil({
  tables: ({ table }) => ({
    blog: table(s.blog, {
      pub: 'published',
      rateLimit: 10,
      softDelete: true
    })
  })
})

Reducers create_blog, update_blog, rm_blog, bulk_rm_blog are auto-registered on the module.

Options reference

OptionTypeEffect
pubboolean | stringGenerates pub.list/read/search for public reads. String form: only show rows where pub === true for that field (e.g. pub: 'published').
softDeletebooleanrm sets deletedAt instead of hard-deleting; auth.list/pub.list filter it out; auto-generates restore({ id }).
rateLimitnumber | { max, window }Sliding-window per-user write limit. Number is shorthand for { max: N, window: 60_000 }. Throws RATE_LIMITED over the limit.
searchstringField to index for full-text search. Convex creates a search index; SpacetimeDB filters client-side.
aclboolean(orgScoped/owned hybrid) Enables editors[] field on rows for shared editing.
cascade{ table, foreignKey }[]Auto-delete child rows when this row is removed.
hooksOwnedHooks<T>Lifecycle: beforeCreate/afterCreate/beforeUpdate/afterUpdate/beforeDelete/afterDelete.

Conflict detection

Pass expectedUpdatedAt from a previous read:

const update = useMutate(api.blog.update)
await update({ id: post._id, expectedUpdatedAt: post.updatedAt, title: 'New' })
// → throws CONFLICT if someone else edited since post was fetched
const update = useMut(reducers.update_blog)
await update({ id: post.id, expectedUpdatedAt: post.updatedAt, title: 'New' })

useForm wires this automatically via Auto Conflict Detection.

Soft-delete + restore

With softDelete: true, rm({ id }) writes deletedAt = now() instead of removing the row. List/read endpoints filter deletedAt != null rows out by default. A restore({ id }) endpoint is auto-generated.

const { restore } = api.blog
await restore({ id: post._id })  // clears deletedAt

Bulk operations

create, update, and rm all accept arrays up to 100 items in a single call:

await api.blog.create([{ title: 'A', content: '...' }, { title: 'B', content: '...' }])
await api.blog.rm({ id: [postA, postB, postC] })

Single-transaction; partial failures roll back everything.

Client hooks

import { useList, useCrud } from 'noboil/convex/react'

// option A: separate
const { items, hasMore, loadMore } = useList(api.blog.pub.list, {})
const create = useMutate(api.blog.create)

// option B: bundled
const { data, create, update, rm, hasMore, loadMore, isLoading } = useCrud(api.blog)
import { useList, useCrud, useMut } from 'noboil/spacetimedb/react'

const { items, hasMore, loadMore } = useList(tables.blog, { where: { published: true } })
const create = useMut(reducers.create_blog)

// or unified:
const { data, create, update, rm } = useCrud({
  table: tables.blog,
  create: reducers.create_blog,
  update: reducers.update_blog,
  rm: reducers.rm_blog
})

When to use owned vs others

  • owned — each row has one owner; the default. Posts, chats, todos.
  • orgScoped — rows belong to an organization, not a user. Multi-tenant. Use when membership/role checks matter. See orgScoped factory.
  • children — rows hang off a parent (with cascade). Comments under posts. See child factory.
  • singleton — exactly one row per user. Profile, preferences. See singleton factory.
  • base — shared/global, no ownership. External API cache. See base factory.

Demo

web/{cvx,stdb}/blog implements a full blog: create/edit/publish/delete posts, public reads of published === true, search by content, soft-delete + restore, conflict detection. 52 Playwright tests cover the full flow on each backend.

On this page