noboil

child factory (parent-of-children)

Tables nested under a parent with cascade-on-delete, parent-scoped reads, and ownership inherited from the parent. For comments under posts, items under orders, messages under chats.

Use children for tables that have a strict foreign-key relationship to a parent table: comments under posts, line items under orders, replies under messages. The factory injects parentId, scopes reads to a parent, inherits ownership checks from the parent, and (with cascade: true) deletes children when the parent is removed.

API surface

type ChildCrudResult = {
  create: Mutation<(ctx, payload: T & { parentId }) => Id>
  update: Mutation<(ctx, { id, ...patch, expectedUpdatedAt? }) => Row>
  rm: Mutation<(ctx, { id }) => { deleted: boolean }>
  list: Query<(ctx, { parentId, paginationOpts }) => PaginatedResult>
  get: Query<(ctx, { id }) => Row | null>
  purgeByParent: Mutation<(ctx, { parentId }) => { deleted: number }>
}
MethodAuthSemantics
create({parentId, ...payload})parent ownerInserts with parent FK; verifies caller owns parent.
update({id, ...})row creator OR parent ownerPatches with optional expectedUpdatedAt.
rm({id})row creator OR parent ownerHard or soft delete.
list({parentId, paginationOpts})parent ownerCursor-paginated; only this parent's children.
get({id})parent ownerSingle row by id; throws NOT_FOUND if unauthorized.
purgeByParent({parentId})parent ownerBulk delete all children of a parent. Called automatically on parent delete with cascade.
const { tables, reducers } = makeChildCrud(spacetimedb, {
  tableName: 'message',
  parentTable: 'chat',
  foreignKey: 'chatId',
  fields,
  table
})
ReducerAuthParamsSemantics
create_messageparent ownerchatId, ...payloadInsert with sender as creator.
update_messagecreator/parentid, ...patch, expectedUpdatedAt?Patch row.
rm_messagecreator/parentidHard or soft delete.
purge_message_by_parentparent ownerchatIdBulk delete; called by cascade.

Schema

import { object, string, array, enum as zenum } from 'zod/v4'
import { zid } from 'convex-helpers/server/zod4'
import { schema, child } from 'noboil/convex/schema'

const children = {
  message: child({
    foreignKey: 'chatId',
    parent: 'chat',
    schema: object({
      chatId: zid('chat'),
      content: string(),
      role: zenum(['user', 'assistant', 'system'])
    })
  })
}
import { object, string, array, enum as zenum } from 'zod/v4'
import { schema, child } from 'noboil/spacetimedb/schema'

const children = {
  message: child({
    foreignKey: 'chatId',
    parent: 'chat',
    schema: object({
      chatId: number(),  // FK type matches parent's id type
      content: string(),
      role: zenum(['user', 'assistant', 'system'])
    })
  })
}

child(...) config:

  • parent — name of the parent table (must exist in schema).
  • foreignKey — field on the child that holds the parent id.
  • schema — Zod object for the row (must include the foreignKey field).

Auto-fields injected:

  • parentId (Convex) / aliased to foreignKey field — indexed
  • userId — creator
  • updatedAt — last write timestamp
  • deletedAt? — when softDelete: true

Index auto-created: by_parent (on the foreignKey field).

Wire it up

// backend/convex/lazy.ts
const api = noboil({
  /* ... */
  tables: ({ table }) => ({
    chat: table(s.chat, {
      cascade: [{ foreignKey: s.message.foreignKey, table: s.message.__name }],
      pub: 'isPublic',
      rateLimit: 30
    }),
    message: table(s.message, {
      pub: { parentField: 'isPublic' }   // public reads if parent chat is public
    })
  })
})
// backend/convex/convex/message.ts
import { api } from '../lazy'
export const { create, update, rm, list, get } = api.message
// backend/spacetimedb/src/index.ts
const spacetimedb = noboil({
  tables: ({ table }) => ({
    chat: table(s.chat, {
      cascade: { table: s.message.__name, foreignKey: 'chatId' },
      pub: 'isPublic'
    }),
    message: table(s.message)
  })
})

Options reference

OptionTypeEffect
pubboolean | { parentField }Public reads. With { parentField: 'X' }, child rows are public iff parent's field X is true.
softDeletebooleanrm writes deletedAt; restore auto-generated.
rateLimitnumber | { max, window }Per-user write limit.
cascade{ table, foreignKey }[] (set on parent)Auto-purge children when parent deleted.
hooksChildHooks<T>beforeCreate/afterCreate/etc. with (ctx, { parentId, ... }).

Cascade on parent delete

When the parent table is configured with cascade: [{ table, foreignKey }], deleting a parent calls purge_<child>_by_parent automatically — children disappear in the same transaction. No manual cleanup.

chat: table(s.chat, {
  cascade: [{ foreignKey: 'chatId', table: 'message' }]
})
// rm({ id: chatId }) → purges all message rows where chatId = X

Inherited public-read

message: table(s.message, {
  pub: { parentField: 'isPublic' }
})

A message row is publicly readable iff its parent chat.isPublic === true. Lets you build "public conversations with private replies still locked" patterns without re-implementing access logic.

Conflict detection

Same as ownedexpectedUpdatedAt on update.

Soft-delete + restore

Same as owned.

Client hooks

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

const { items, hasMore, loadMore } = useList(api.message.list, { parentId: chatId })
const create = useMutate(api.message.create)
await create({ chatId, content: 'hello', role: 'user' })
import { useList, useMut } from 'noboil/spacetimedb/react'

const { items, hasMore, loadMore } = useList(tables.message, { where: { chatId } })
const create = useMut(reducers.create_message)
await create({ chatId, content: 'hello', role: 'user' })

When to use children vs others

  • children — strictly nested under a parent; ownership inherited; cascade on parent delete. Comments, line items, messages.
  • owned — independent rows owned by one user. See owned factory.
  • log — append-only with per-parent atomic seq, idempotency, no updates. Audit trails. See log factory.
  • orgScoped — belongs to an org, not a parent row. See orgScoped factory.

Demo

web/{cvx,stdb}/chat implements a chat app: chat owners create messages under their chats, public chats expose messages publicly via parentField: 'isPublic', deleting a chat cascades to all messages. Both backends, full Playwright coverage.

On this page