noboil

orgScoped factory (org-scoped CRUD with editors + ACL)

Org-scoped CRUD with membership checks, role gating, optional editors-list ACL, and cascade. For multi-tenant team-shared resources — wikis, projects, tasks.

Use orgScoped for tables where rows belong to an organization, not a user. Every endpoint scopes by orgId, verifies the caller is a member, and (when acl: true) checks the caller is in the row's editors[] array. Pair with the org definition (makeOrg) to wire membership tables, invites, and role checks automatically.

API surface

type OrgCrudResult = {
  create: Mutation<(ctx, payload: T & { orgId: Id<'org'> }) => Id>
  update: Mutation<(ctx, { id, ...patch, expectedUpdatedAt? }) => Row>
  rm: Mutation<(ctx, { id }) => { deleted: boolean }>
  list: Query<(ctx, { orgId, paginationOpts }) => PaginatedResult>
  read: Query<(ctx, { id }) => Row | null>
  search: Query<(ctx, { orgId, query }) => Row[]>
  addEditor: Mutation<(ctx, { id, userId }) => Row>      // when acl: true
  removeEditor: Mutation<(ctx, { id, userId }) => Row>   // when acl: true
}
MethodAuthSemantics
create({orgId, ...payload})org memberInserts with userId = ctx.user._id, orgId, updatedAt.
update({id, ...})org member + (creator OR editor OR admin)Patches; throws INSUFFICIENT_ORG_ROLE otherwise.
rm({id})org member + sameHard or soft delete.
list({orgId, paginationOpts})org memberCursor-paginated; orgId filtered.
read({id})org memberThrows NOT_FOUND if not in caller's orgs.
addEditor({id, userId})row creator OR adminAdds userId to editors[].
removeEditor({id, userId})row creator OR adminRemoves userId from editors[].
const { tables, reducers } = makeOrgCrud(spacetimedb, {
  tableName: 'wiki',
  fields,
  idField: 'id',
  pk,
  table
})

Generated reducers:

ReducerAuthParamsSemantics
create_wikiorg memberorgId, ...payloadInserts with sender as creator.
update_wikicreator/editor/adminid, ...patch, expectedUpdatedAt?Throws INSUFFICIENT_ORG_ROLE.
rm_wikicreator/editor/adminidHard or soft delete.
add_editor_wikicreator/adminid, userIdAppends to editors[].
remove_editor_wikicreator/adminid, userIdRemoves from editors[].

Schema

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

const org = makeOrg()  // membership tables, invites
const orgScoped = makeOrgScoped({
  wiki: object({
    title: string().min(1),
    content: string().optional(),
    status: zenum(['draft', 'published']),
    editors: array(zid('users')).max(100).optional()
  })
})
import { object, string, array, enum as zenum } from 'zod/v4'
import { schema, makeOrg, makeOrgScoped } from 'noboil/spacetimedb/schema'

const org = makeOrg()
const orgScoped = makeOrgScoped({
  wiki: object({
    title: string().min(1),
    content: string().optional(),
    status: zenum(['draft', 'published'])
  })
})

Auto-fields injected:

  • userId — creator identity
  • orgId — owning org (indexed)
  • updatedAt — last write timestamp
  • editors?: Id<'users'>[] — when acl: true
  • deletedAt? — when softDelete: true

Indexes auto-created: by_org, by_org_user.

Wire it up

// backend/convex/lazy.ts
const api = noboil({
  /* ... */
  orgSchema: s.team,        // pass the org definition
  tables: ({ table }) => ({
    wiki: table(s.wiki, {
      acl: true,                              // enable editors[] ACL
      rateLimit: 30,
      softDelete: true
    }),
    project: table(s.project, {
      acl: true,
      cascade: orgCascade(s.task, { foreignKey: 'projectId' })  // delete tasks when project deleted
    }),
    task: table(s.task, {
      aclFrom: { field: 'projectId', table: s.project.__name }  // inherit ACL from parent project
    })
  })
})
// backend/convex/convex/wiki.ts
import { api } from '../lazy'
export const { create, update, rm, list, read, search, addEditor, removeEditor } = api.wiki
// backend/spacetimedb/src/index.ts
const spacetimedb = noboil({
  orgSchema: s.team,
  tables: ({ table }) => ({
    wiki: table(s.wiki, { acl: true, softDelete: true }),
    project: table(s.project, { acl: true }),
    task: table(s.task, { aclFrom: { field: 'projectId', table: s.project.__name } })
  })
})

Options reference

OptionTypeEffect
aclbooleanEnables editors[] field + addEditor/removeEditor reducers; non-creators must be in editors to write.
aclFrom{ field, table }Inherits ACL from parent row (e.g. tasks inherit from projects).
cascade{ table, foreignKey }[]Auto-deletes children when row removed. Use orgCascade(...) helper.
softDeletebooleanrm writes deletedAt; auto-generates restore.
rateLimitnumber | { max, window }Per-user write limit.
uniquestring[]Enforces a unique compound index (e.g. ['orgId', 'slug']).
hooksOrgHooks<T>beforeCreate/afterUpdate/etc. with (ctx, { orgId, ... }).

Membership + roles

makeOrg() generates these tables/endpoints automatically — you don't write them:

Table / endpointPurpose
orgThe org rows themselves (name, slug, owner).
orgMemberuserId × orgId join with role (owner/admin/member).
orgInvitePending invites with token + expiry.
joinOrg({orgId})Self-join (if open) or create join request.
acceptInvite({token})Consume an invite token.
setMemberRole({orgId, userId, role})Admin-only role mutation.
removeMember({orgId, userId})Admin-only or self-leave.

See organizations for the full membership lifecycle, invite flow, and role-checking helpers.

Cascade & aclFrom

project: table(s.project, {
  acl: true,
  cascade: orgCascade(s.task, { foreignKey: 'projectId' })
})
task: table(s.task, {
  aclFrom: { field: 'projectId', table: s.project.__name }
})
  • cascade: deleting a project deletes all its tasks in the same transaction.
  • aclFrom: a task's "can edit" check defers to the project's ACL. Tasks don't need their own editors[].

Conflict detection

Same as owned — pass expectedUpdatedAt from a previous read; throws CONFLICT on stale.

Soft-delete + restore

With softDelete: true, rm writes deletedAt; list/read filter it out; restore({ id }) reverses it.

Client hooks

import { useOrgQuery, useMutate, useOrgRole } from 'noboil/convex/react'

const { items, hasMore, loadMore } = useOrgQuery(api.wiki.list, {})  // orgId injected
const create = useMutate(api.wiki.create)
const role = useOrgRole()  // 'owner' | 'admin' | 'member' | undefined

useOrgQuery reads the active org from <OrgProvider> context and injects orgId automatically — never pass it manually.

import { useList, useMut } from 'noboil/spacetimedb/react'
import { useSpacetimeDB } from 'spacetimedb/react'

const { activeOrgId } = useSpacetimeDB()
const { items } = useList(tables.wiki, { where: { orgId: activeOrgId } })
const create = useMut(reducers.create_wiki)

When to use orgScoped vs others

  • orgScoped — multi-tenant, team-shared, role-gated. Wikis, projects, tasks.
  • owned — single user owns it. Personal blog, private notes. See owned factory.
  • children — strictly nested under a parent, no separate org. Comments under posts. See child factory.

Demo

web/{cvx,stdb}/org implements a full multi-tenant app: create org, invite members, role gate (owner/admin/member), wiki with editors[], projects with cascading tasks. Both backends, full Playwright coverage.

On this page