noboil

Log factory (append-only event log)

Append-only sequential tables with atomic seq allocation, idempotent appends, soft-delete + restore, bulk operations, and parent-scoped listing. For messaging, audit trails, activity feeds, event sourcing.

Use log for tables where rows are append-only and ordered by a per-parent sequence number — chat messages, stream events, audit trails, activity feeds, vote ballots. Auto-assigns a seq (atomic counter per parent), supports idempotent appends, soft-delete + restore, bulk operations, and pub/auth split for visibility scoping.

API surface

type LogFactoryResult = {
  // writes
  append: Mutation<(ctx, { parent: string, payload: T, idempotencyKey?: string }) => { seq: number, created: boolean }>
  appendBulk: Mutation<(ctx, { parent: string, items: T[] }) => { count: number }>
  rm: Mutation<(ctx, { id: Id<table> }) => { deleted: boolean }>
  rmBulk: Mutation<(ctx, { ids: Id<table>[] }) => { count: number }>
  update: Mutation<(ctx, { id, patch, expectedUpdatedAt? }) => Row>
  purgeByParent: Mutation<(ctx, { parent: string }) => { deleted: number }>
  restoreByParent: Mutation<(ctx, { parent: string }) => { restored: number }>

  // reads (auth — owner of the parent)
  auth: {
    list: Query<(ctx, { parent: string, paginationOpts }) => PaginatedResult>
    listAfter: Query<(ctx, { parent: string, seq: number, limit?: number }) => Row[]>
    read: Query<(ctx, { id: Id<table> }) => Row | null>
    search: Query<(ctx, { parent: string, query: string }) => Row[]>
  }
  authIndexed: Query<(ctx, { parent: string, indexBy: string }) => Record<string, Row[]>>

  // reads (public — visible to everyone)
  pub?: { list, listAfter, read, search }
}
const { tables, reducers } = makeLog(spacetimedb, {
  tableName: 'vote',
  parentTable: 'poll',
  fields,
  table
})

Generated reducers per vote:

ReducerParamsSemantics
append_voteparent, ...payload, idempotency_key?Atomic seq + insert. Returns existing on idempotency-key match.
bulk_append_voteparent, items[]Single-transaction batch append.
update_voteid, ...patch, expectedUpdatedAt?Author-only patch. Throws CONFLICT on stale.
rm_voteidHard or soft delete.
bulk_rm_voteids[]Batch delete.
purge_vote_by_parentparentDelete all rows for a parent.
restore_vote_by_parentparentClear deletedAt on all rows for parent (when softDelete: true).

Reads happen via subscription on tables.vote filtered client-side by parent.

MethodSemantics
append(parent, payload, idempotencyKey?)Atomic seq = nextSeq(parent) + insert. With idempotency key: returns existing {seq, created: false} if (parent, key) already exists.
appendBulk(parent, items)Single-transaction batch insert, contiguous seq range. Faster than N append calls when items > 1.
update(id, patch)Mutates a row with optional expectedUpdatedAt conflict check. Hooks fire.
rm(id) / rmBulk(ids)Hard-delete row(s). With softDelete: true, sets deletedAt instead.
purgeByParent(parent)Bulk soft- or hard-delete all rows for parent. Call on parent row delete.
restoreByParent(parent)If softDelete: true, clears deletedAt on all matching rows.
auth.list(parent, paginationOpts)Owner-only paginated read, newest-first.
auth.listAfter(parent, seq, limit?)Streaming tail read for seq > N. Default limit 500.
auth.read(id)Single row by id, ownership-checked.
auth.search(parent, query)Free-text search if search config provided.
pub.*Same surface but public-readable when pub config provided.
*IndexedGroup rows by a field for "votes per option" style aggregation.

Schema

import { object, string, number, enum as zenum } from 'zod/v4'
import { schema, makeLog } from 'noboil/convex/schema'

const log = makeLog({
  vote: {
    parent: 'poll',
    schema: object({
      optionIdx: number(),
      voter: string()
    })
  },
  message: {
    parent: 'chat',
    schema: object({
      content: string(),
      role: zenum(['user', 'assistant', 'system'])
    })
  }
})
import { object, string, number, enum as zenum } from 'zod/v4'
import { schema, makeLog } from 'noboil/spacetimedb/schema'

const log = makeLog({
  vote: {
    parent: 'poll',
    schema: object({ optionIdx: number(), voter: string() })
  },
  message: {
    parent: 'chat',
    schema: object({
      content: string(),
      role: zenum(['user', 'assistant', 'system'])
    })
  }
})

Auto-fields injected:

  • parent — foreign key (indexed)
  • seq: number — monotonic per parent, starts at 1
  • idempotencyKey?: string — optional dedup hash
  • userId — author identity
  • createdAt / updatedAt — timestamps
  • deletedAt? — when softDelete: true and row purged
  • _creationTime (Convex native)

Indexes auto-created: by_parent_seq, by_idempotency (when used).

Wire it up

// backend/convex/lazy.ts
const api = noboil({
  /* ... */
  tables: ({ table }) => ({
    poll: table(s.poll),
    vote: table(s.vote, {
      softDelete: true,        // enable purge/restore
      rateLimit: 30,           // max 30 appends per user/minute
      pub: 'isPublic',         // expose pub.* reads when poll.isPublic = true
      hooks: {
        beforeAppend: async (ctx, { parent, payload }) => ({ ...payload, voter: ctx.user._id }),
        afterAppend: async (ctx, { row }) => {/* notify subscribers */},
        beforeRm: async (ctx, { id, prev }) => {/* gate / audit */},
        afterRm: async (ctx, { id }) => {/* notify */}
      }
    })
  })
})
// backend/convex/convex/vote.ts (re-export to expose RPC)
import { api } from '../lazy'
export const {
  append, appendBulk, auth, authIndexed, list, listAfter,
  purgeByParent, read, restoreByParent, rm, update
} = api.vote
// backend/spacetimedb/src/index.ts
const spacetimedb = noboil({
  tables: ({ table }) => ({
    poll: table(s.poll),
    vote: table(s.vote, {
      softDelete: true,
      rateLimit: 30
    })
  })
})

Reducers append_vote, bulk_append_vote, update_vote, rm_vote, purge_vote_by_parent, restore_vote_by_parent are auto-registered.

Options reference

OptionTypeEffect
softDeletebooleanEnables deletedAt flag; purgeByParent sets timestamps; restoreByParent clears them. Hard-delete still available via rm({hard:true}).
rateLimitnumber | RateLimitConfigSliding-window limit on appends per user.
pubstring | { parentField: string }Field on the parent row that gates public visibility.
searchstringField to enable full-text search on (server-side withSearchIndex).
hooksLogHooksbeforeAppend / afterAppend / beforeRm / afterRm / beforeUpdate / afterUpdate.
withAuthorbooleanAuto-join users row into reads.

Client hooks

import { useLog } from 'noboil/convex/react'
const log = useLog(api.vote, { parent: pollId })

log.data           // Row[]
log.isLoading
log.hasMore
log.loadMore(20)

await log.append({ payload: { optionIdx: 0, voter: 'me' } })
await log.appendBulk([{ optionIdx: 0, voter: 'a' }, { optionIdx: 1, voter: 'b' }])
await log.update(rowId, { optionIdx: 1 })
await log.rm(rowId)
await log.rmBulk([id1, id2])
await log.purge()       // calls purgeByParent for current parent
await log.restore()     // calls restoreByParent for current parent
import { useLog } from 'noboil/spacetimedb/react'
const log = useLog(
  {
    table: tables.vote,
    append: reducers.append_vote,
    bulkAppend: reducers.bulk_append_vote,
    rm: reducers.rm_vote,
    purge: reducers.purge_vote_by_parent
  },
  { parent: pollId }
)

await log.append({ payload: { optionIdx: 0, voter: 'me' } })
await log.purge()

Seq allocation

Counter row per parent stored in internal _logCounters table (one row per {table, parent}). append reads counter, increments atomically, inserts row.

  • Convex mutations are transactional, so the read+increment+write is safe.
  • SpacetimeDB uses auto_inc columns; the reducer enforces per-parent monotonicity.

Idempotency

When idempotencyKey is provided, the factory queries by_idempotency first. If found → return existing {seq, created: false}. If not → atomic insert. Guarantees insert-or-noop semantics for retryable client writes.

Without idempotencyKey → every append inserts (duplicates allowed; caller responsible for dedup).

What log deliberately doesn't do

  • No bare update without authoring — events have update/rm for fixing typos, but the row's seq and parent are immutable (preserved across update). Use owned or child if you want fully mutable state.
  • No auto-TTL — retention is explicit via purgeByParent. Add a cron if you want "delete events older than N days".
  • No cross-parent queries — every op is scoped by parent. Cross-table analytics belong in a separate read-only view.
  • No subscriptions API — consume via Convex's native useQuery on listAfter for streaming, or useList for paginated history. Factory generates the queries; you get subscriptions for free.

Hooks

type LogHooks<T> = {
  beforeAppend?: (ctx, { parent, payload }) => T | Promise<T>
  afterAppend?: (ctx, { row }) => Promise<void>
  beforeUpdate?: (ctx, { id, patch, prev }) => Patch | Promise<Patch>
  afterUpdate?: (ctx, { id, patch, prev }) => Promise<void>
  beforeRm?: (ctx, { id, prev }) => Promise<void>
  afterRm?: (ctx, { id }) => Promise<void>
}

Use cases: stamp author, derive denormalized fields, fire-and-forget side effects (notifications), audit log writes.

Example: chat messages with idempotency

const s = schema({
  owned: { chat: object({ title: string(), userId: zid('users') }) },
  log: {
    message: { parent: 'chat', schema: object({ content: string(), role: zenum(['user','assistant','system']) }) }
  }
})

Client retry-safe send:

const tempId = crypto.randomUUID()
const { seq, created } = await log.append({
  payload: { content, role: 'user' },
  idempotencyKey: tempId
})
if (!created) console.log('Already sent, seq:', seq)
const s = schema({
  owned: { chat: object({ title: string() }) },
  log: {
    message: { parent: 'chat', schema: object({ content: string(), role: zenum(['user','assistant','system']) }) }
  }
})
const tempId = crypto.randomUUID()
const { seq, created } = await log.append({
  payload: { content, role: 'user' },
  idempotencyKey: tempId
})

When to use log vs child

Use log ifUse child if
Rows are append-only with optional updatesRows are routinely edited
Strict per-parent seq ordering mattersOrder by _creationTime is fine
Idempotent retries expectedDuplicates are errors
Soft-delete + restore + bulk neededSingle delete is fine
Common patterns: messages, votes, events, auditCommon patterns: comments, todos under a project

Demo

The poll demo uses log for vote rows on both backends: each ballot is appended with {optionIdx, voter}, listed paginated, purged + restored as admin actions, and counted via useLog().data aggregation.

On this page