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:
| Reducer | Params | Semantics |
|---|---|---|
append_vote | parent, ...payload, idempotency_key? | Atomic seq + insert. Returns existing on idempotency-key match. |
bulk_append_vote | parent, items[] | Single-transaction batch append. |
update_vote | id, ...patch, expectedUpdatedAt? | Author-only patch. Throws CONFLICT on stale. |
rm_vote | id | Hard or soft delete. |
bulk_rm_vote | ids[] | Batch delete. |
purge_vote_by_parent | parent | Delete all rows for a parent. |
restore_vote_by_parent | parent | Clear deletedAt on all rows for parent (when softDelete: true). |
Reads happen via subscription on tables.vote filtered client-side by parent.
| Method | Semantics |
|---|---|
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. |
*Indexed | Group 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 1idempotencyKey?: string— optional dedup hashuserId— author identitycreatedAt/updatedAt— timestampsdeletedAt?— whensoftDelete: trueand 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
| Option | Type | Effect |
|---|---|---|
softDelete | boolean | Enables deletedAt flag; purgeByParent sets timestamps; restoreByParent clears them. Hard-delete still available via rm({hard:true}). |
rateLimit | number | RateLimitConfig | Sliding-window limit on appends per user. |
pub | string | { parentField: string } | Field on the parent row that gates public visibility. |
search | string | Field to enable full-text search on (server-side withSearchIndex). |
hooks | LogHooks | beforeAppend / afterAppend / beforeRm / afterRm / beforeUpdate / afterUpdate. |
withAuthor | boolean | Auto-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 parentimport { 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_inccolumns; 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
updatewithout authoring — events haveupdate/rmfor fixing typos, but the row'sseqandparentare immutable (preserved across update). Useownedorchildif 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
useQueryonlistAfterfor streaming, oruseListfor 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 if | Use child if |
|---|---|
| Rows are append-only with optional updates | Rows are routinely edited |
| Strict per-parent seq ordering matters | Order by _creationTime is fine |
| Idempotent retries expected | Duplicates are errors |
| Soft-delete + restore + bulk needed | Single delete is fine |
| Common patterns: messages, votes, events, audit | Common 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.
base factory (external API cache)
Keyed external-data cache with TTL, refresh, invalidate, and stale-while-revalidate. For TMDB lookups, Gravatar avatars, OpenWeather data — anything fetched from an upstream API.
kv factory (string-keyed global state)
Named key → value state with role-gated writes, public reads, soft-delete + restore, conflict detection, and optional key whitelist. For site config, feature flags, banners, system status.