noboil

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
})
ReducerParamsSemantics
record_pollVoteQuotaownerAppend timestamp + prune. Returns new state.
consume_pollVoteQuotaownerAtomic check + record. Returns allowed: false if over.

Reads via subscription on tables.pollVoteQuota (compute allowed/remaining client-side from timestamps[]).

MethodPureSemantics
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

OptionTypeEffect
hooksQuotaHooksbeforeConsume / 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:

  • userId for authenticated per-user limits
  • email for pre-auth form submissions
  • ip for public endpoints (x-forwarded-for from 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 success

Client 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 kv counter 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").

On this page