quota factory (sliding window)
Per-owner sliding-window rate limit primitive with check / record / consume semantics, hooks, and ergonomic React integration. For anti-spam, API throttling, ballot-stuffing prevention.
Use quota for sliding-window rate limits per owner — anti-spam on form submissions, API throttling per user, ballot-stuffing prevention on polls, typing-indicator debouncing. Storage is per-owner with a timestamps: number[] ring; pruning happens on write.
API surface
type QuotaFactoryResult = {
check: Query<(ctx, { owner: string }) => QuotaResult>
record: Mutation<(ctx, { owner: string }) => QuotaResult>
consume: Mutation<(ctx, { owner: string }) => QuotaResult>
}
type QuotaResult = { allowed: boolean, remaining: number, retryAfter?: number }const { tables, reducers } = makeQuota(spacetimedb, {
tableName: 'pollVoteQuota',
fields,
table
})| Reducer | Params | Semantics |
|---|---|---|
record_pollVoteQuota | owner | Append timestamp + prune. Returns new state. |
consume_pollVoteQuota | owner | Atomic check + record. Returns allowed: false if over. |
Reads via subscription on tables.pollVoteQuota (compute allowed/remaining client-side from timestamps[]).
| Method | Pure | Semantics |
|---|---|---|
check(owner) | ✓ (query) | Returns current state without mutating. Use for UI — "3 left this minute". |
record(owner) | ✗ (mutation) | Appends timestamp, prunes expired entries, returns new state. Records even if already over limit. |
consume(owner) | ✗ (mutation) | Atomic: checks, records IF allowed, returns result. Returns allowed: false if over — caller should reject. The typical "before mutation" gate. |
Schema
import { schema, makeQuota } from 'noboil/convex/schema'
const limits = makeQuota({
messageSend: { limit: 10, durationMs: 60_000 }, // 10/min per owner
pollVote: { limit: 30, durationMs: 60_000 }, // 30/min per owner
signup: { limit: 5, durationMs: 60 * 60 * 1000 } // 5/hr per IP
})import { schema, makeQuota } from 'noboil/spacetimedb/schema'
const limits = makeQuota({
pollVoteQuota: { limit: 30, durationMs: 60_000 }
})Auto-fields in the backing table:
owner: string— the rate-limit key (userId, email, IP, composite)timestamps: number[]— recent timestamps within the window_creationTime: number— Convex native
Index auto-created: by_owner (unique).
Wire it up
// backend/convex/lazy.ts
const api = noboil({
/* ... */
tables: ({ table }) => ({
pollVoteQuota: table(s.pollVote, {
hooks: {
beforeConsume: async (ctx, { owner }) => true, // return false to deny
afterConsume: async (ctx, { owner, result }) => {/* metrics */},
onExceeded: async (ctx, { owner, result }) => {
await audit({ ctx, kind: 'rate_limit_exceeded', owner })
}
}
})
})
})// backend/convex/convex/pollVoteQuota.ts (re-export RPC)
import { api } from '../lazy'
export const { check, consume, record } = api.pollVoteQuota// backend/spacetimedb/src/index.ts
const spacetimedb = noboil({
tables: ({ table }) => ({
pollVoteQuota: table(s.pollVoteQuota)
})
})Reducers consume_pollVoteQuota, record_pollVoteQuota are auto-registered.
Options reference
| Option | Type | Effect |
|---|---|---|
hooks | QuotaHooks | beforeConsume / afterConsume / beforeRecord / afterRecord / onExceeded. |
(Quota tables don't take softDelete, pub, etc. — they are pure rate-limit primitives.)
Pruning
Entries older than durationMs are pruned on every record / consume. No background cleanup cron needed. If a row sees no writes, it stays — but check returns allowed: true / remaining: limit because pruning is computed on read (not written). Dead rows are harmless; add a nightly cron if you care about storage.
Owner key conventions
owner is opaque string — pick what makes sense:
userIdfor authenticated per-user limitsemailfor pre-auth form submissionsipfor public endpoints (x-forwarded-forfrom HTTP action)${userId}:${resource}for per-user-per-resource limits (e.g.user123:poll456)
Hooks
type QuotaHooks = {
beforeConsume?: (ctx, { owner }) => boolean | Promise<boolean> // false → deny
afterConsume?: (ctx, { owner, result }) => Promise<void>
beforeRecord?: (ctx, { owner }) => Promise<void>
afterRecord?: (ctx, { owner, result }) => Promise<void>
onExceeded?: (ctx, { owner, result }) => Promise<void> // fires when allowed=false
}onExceeded is the natural place to log abuse, queue a flag-for-review, or trigger an alert.
Typical patterns
Gate a mutation:
export const send = mutation({
args: { chatId: v.id('chats'), content: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
const rl = await api.limits.messageSend.consume(ctx, { owner: userId })
if (!rl.allowed) throw new Error(`rate limit; retry in ${rl.retryAfter}ms`)
// ... actual send logic
}
})Show UI state:
const { allowed, remaining, retryAfter } = useQuery(api.limits.messageSend.check, { owner: userId })
return <Button disabled={!allowed}>Send ({remaining} left)</Button>Split check from record for deferred actions:
const pre = await api.limits.vote.check(ctx, { owner: userId })
if (!pre.allowed) throw new Error('slow down')
// ... async work that might fail ...
await api.limits.vote.record(ctx, { owner: userId }) // only record after successClient hook
import { useQuota } from 'noboil/convex/react'
const quota = useQuota(api.pollVoteQuota, pollId)
quota.state // { allowed, remaining, retryAfter? } (subscribed via check)
const result = await quota.consume()
const after = await quota.record()import { useQuota } from 'noboil/spacetimedb/react'
const quota = useQuota(
{
table: tables.pollVoteQuota,
consume: reducers.consume_pollVoteQuota,
record: reducers.record_pollVoteQuota
},
pollId
)
quota.state // { allowed, remaining, retryAfter? }
await quota.consume()What quota deliberately doesn't do
- No global limit — every limit is per-owner. For global limits ("100 total signups/hour"), pair with a
kvcounter row. - No fixed-window variant — sliding window only. Fixed bucket is cheaper but less precise.
- No persistence across server restarts — it's a normal table, so survives whatever the backend survives.
- No distributed rate limit — single-region per database.
Composing patterns
Pair quota with log for rate-limited append-only writes:
export const submitVote = mutation({
args: { pollId: v.string(), optionIdx: v.number() },
handler: async (ctx, { pollId, optionIdx }) => {
const userId = await getAuthUserId(ctx)
const rl = await api.pollVoteQuota.consume(ctx, { owner: `${userId}:${pollId}` })
if (!rl.allowed) throw new Error('VOTE_RATE_LIMITED')
await api.vote.append(ctx, { parent: pollId, payload: { optionIdx, voter: userId } })
}
})The vote table is log (append-only, ordered by seq); the quota gates writes per (user, poll) pair.
Demo
The poll demo uses quota (pollVoteQuota) to cap a user to 30 votes/minute on a poll. The vote button is disabled by quota.state.allowed, and the remaining count is shown live ("23 left this minute").
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.
Composing mutations
Factories generate atomic CRUD endpoints. Compose them for domain operations like "send message" = append to log + bump parent timestamp.