Skip to Content
⚠️Active Development Notice: TimeTiles is under active development. Information may be placeholder content or not up-to-date.

web


web / lib/services/quota-service

lib/services/quota-service

Service for managing user quotas and resource limits.

This service provides centralized control over user resource limits, usage tracking, and quota enforcement. It integrates with Payload CMS to enforce quotas and track usage across various operations like file uploads, scheduled imports, and event creation.

Usage Tracking Architecture

Usage tracking is stored in a separate user-usage collection rather than embedded in the users collection. This separation:

  • Prevents session-clearing issues that occurred when versioning was enabled on users
  • Isolates authentication data from usage tracking
  • Allows independent scaling and optimization of usage tracking

Quotas vs Rate Limiting

This service works alongside RateLimitService but serves a different purpose:

QuotaService (this service):

  • Purpose: Long-term resource management (fair usage, capacity planning)
  • Storage: Database (persistent, accurate) in user-usage collection
  • Scope: Per user ID
  • Time windows: Hours to lifetime (e.g., daily, total)
  • Reset: Fixed times (midnight UTC for daily quotas)
  • Examples: 10 uploads per day, 50,000 total events

RateLimitService:

  • Purpose: Short-term abuse prevention (DDoS, spam, burst attacks)
  • Storage: In-memory (fast, ephemeral)
  • Scope: Per IP address or identifier
  • Time windows: Seconds to hours
  • Reset: Sliding windows
  • Examples: 1 upload per 5 seconds, 5 per hour

Both checks typically run together - rate limits first (fast fail), then quotas (accurate tracking).

Example

// Typical usage pattern: check both rate limits and quotas import { getRateLimitService } from '@/lib/services/rate-limit-service'; import { createQuotaService } from '@/lib/services/quota-service'; // 1. Rate limit check (fast, prevents abuse) const rateLimitService = getRateLimitService(payload); const rateCheck = rateLimitService.checkTrustLevelRateLimit( clientIp, user, "FILE_UPLOAD" ); if (!rateCheck.allowed) { return res.status(429).json({ error: "Too many requests" }); } // 2. Quota check (accurate, tracks long-term usage) const quotaService = createQuotaService(payload); const quotaCheck = await quotaService.checkQuota( user, "FILE_UPLOADS_PER_DAY" ); if (!quotaCheck.allowed) { throw new QuotaExceededError( quotaCheck.quotaKey, quotaCheck.current, quotaCheck.limit, quotaCheck.resetTime ); } // 3. Process the request and track usage await processFileUpload(); await quotaService.incrementUsage(user.id, "FILE_UPLOADS_PER_DAY", 1);

See

RateLimitService for short-term abuse prevention

Classes

QuotaExceededError

Custom error class for quota exceeded scenarios.

Extends

  • Error

Constructors

Constructor

new QuotaExceededError(quotaKey, current, limit, resetTime?): QuotaExceededError

Parameters
quotaKey

"ACTIVE_SCHEDULES" | "URL_FETCHES_PER_DAY" | "FILE_UPLOADS_PER_DAY" | "EVENTS_PER_IMPORT" | "TOTAL_EVENTS" | "IMPORT_JOBS_PER_DAY" | "FILE_SIZE_MB" | "CATALOGS_PER_USER"

current

number

limit

number

resetTime?

Date

Returns

QuotaExceededError

Overrides

Error.constructor

Properties

stackTraceLimit

static stackTraceLimit: number

The Error.stackTraceLimit property specifies the number of stack frames collected by a stack trace (whether generated by new Error().stack or Error.captureStackTrace(obj)).

The default value is 10 but may be set to any valid JavaScript number. Changes will affect any stack trace captured after the value has been changed.

If set to a non-number value, or set to a negative number, stack traces will not capture any frames.

Inherited from

Error.stackTraceLimit

cause?

optional cause: unknown

Inherited from

Error.cause

name

name: string

Inherited from

Error.name

message

message: string

Inherited from

Error.message

stack?

optional stack: string

Inherited from

Error.stack

statusCode

statusCode: number = 429

quotaKey

quotaKey: "ACTIVE_SCHEDULES" | "URL_FETCHES_PER_DAY" | "FILE_UPLOADS_PER_DAY" | "EVENTS_PER_IMPORT" | "TOTAL_EVENTS" | "IMPORT_JOBS_PER_DAY" | "FILE_SIZE_MB" | "CATALOGS_PER_USER"

current

current: number

limit

limit: number

resetTime?

optional resetTime: Date

Methods

captureStackTrace()

static captureStackTrace(targetObject, constructorOpt?): void

Creates a .stack property on targetObject, which when accessed returns a string representing the location in the code at which Error.captureStackTrace() was called.

const myObject = {}; Error.captureStackTrace(myObject); myObject.stack; // Similar to `new Error().stack`

The first line of the trace will be prefixed with ${myObject.name}: ${myObject.message}.

The optional constructorOpt argument accepts a function. If given, all frames above constructorOpt, including constructorOpt, will be omitted from the generated stack trace.

The constructorOpt argument is useful for hiding implementation details of error generation from the user. For instance:

function a() { b(); } function b() { c(); } function c() { // Create an error without stack trace to avoid calculating the stack trace twice. const { stackTraceLimit } = Error; Error.stackTraceLimit = 0; const error = new Error(); Error.stackTraceLimit = stackTraceLimit; // Capture the stack trace above function b Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace throw error; } a();
Parameters
targetObject

object

constructorOpt?

Function

Returns

void

Inherited from

Error.captureStackTrace

prepareStackTrace()

static prepareStackTrace(err, stackTraces): any

Parameters
err

Error

stackTraces

CallSite[]

Returns

any

See

https://v8.dev/docs/stack-trace-api#customizing-stack-traces 

Inherited from

Error.prepareStackTrace


QuotaService

Service for managing user quotas and resource limits.

Constructors

Constructor

new QuotaService(payload): QuotaService

Parameters
payload

BasePayload

Returns

QuotaService

Methods

getOrCreateUsageRecord()

getOrCreateUsageRecord(userId, req?): Promise<UserUsage>

Get or create usage record for a user from the user-usage collection. Uses upsert pattern to ensure usage record exists.

Parameters
userId

UserIdentifier

req?

Partial<PayloadRequest>

Optional PayloadRequest to reuse the caller’s transaction

Returns

Promise<UserUsage>

getEffectiveQuotas()

getEffectiveQuotas(user): UserQuotas

Get effective quotas for a user, considering trust level and custom overrides.

Parameters
user

User | null | undefined

Returns

UserQuotas

getCurrentUsage()

getCurrentUsage(userId): Promise<UserUsage | null>

Get current usage for a user from the user-usage collection.

Parameters
userId

UserIdentifier

Returns

Promise<UserUsage | null>

checkQuota()

checkQuota(user, quotaKey, amount?, req?): Promise<QuotaCheckResult>

Check if a user can perform an action based on quota limits. Now async since it reads from the separate user-usage collection.

Parameters
user

User | null | undefined

quotaKey

"ACTIVE_SCHEDULES" | "URL_FETCHES_PER_DAY" | "FILE_UPLOADS_PER_DAY" | "EVENTS_PER_IMPORT" | "TOTAL_EVENTS" | "IMPORT_JOBS_PER_DAY" | "FILE_SIZE_MB" | "CATALOGS_PER_USER"

amount?

number = 1

req?
context?

Record<string, unknown>

Returns

Promise<QuotaCheckResult>

incrementUsage()

incrementUsage(userId, quotaKey, amount?, req?): Promise<void>

Increment usage counter for a user in the user-usage collection.

Uses atomic SQL UPDATE to prevent race conditions from concurrent requests. The column is incremented directly in the database rather than using a read-modify-write pattern that could lose updates.

Parameters
userId

UserIdentifier

quotaKey

"ACTIVE_SCHEDULES" | "URL_FETCHES_PER_DAY" | "FILE_UPLOADS_PER_DAY" | "EVENTS_PER_IMPORT" | "TOTAL_EVENTS" | "IMPORT_JOBS_PER_DAY" | "FILE_SIZE_MB" | "CATALOGS_PER_USER"

amount?

number = 1

req?

Partial<PayloadRequest>

Returns

Promise<void>

decrementUsage()

decrementUsage(userId, quotaKey, amount?, req?): Promise<void>

Decrement usage counter for a user (e.g., when a schedule is disabled).

Uses atomic SQL UPDATE with GREATEST to prevent going below zero and avoid race conditions from concurrent requests.

Parameters
userId

UserIdentifier

quotaKey

"ACTIVE_SCHEDULES" | "URL_FETCHES_PER_DAY" | "FILE_UPLOADS_PER_DAY" | "EVENTS_PER_IMPORT" | "TOTAL_EVENTS" | "IMPORT_JOBS_PER_DAY" | "FILE_SIZE_MB" | "CATALOGS_PER_USER"

amount?

number = 1

req?

Partial<PayloadRequest>

Returns

Promise<void>

resetDailyCounters()

resetDailyCounters(userId): Promise<void>

Reset daily counters for a user.

Parameters
userId

UserIdentifier

Returns

Promise<void>

resetAllDailyCounters()

resetAllDailyCounters(): Promise<void>

Reset daily counters for all users (called by background job).

Uses Payload’s bulk update API with an empty where clause to update all user-usage records in a single operation.

Returns

Promise<void>

validateQuota()

validateQuota(user, quotaKey, amount?): Promise<void>

Validate a quota check and throw if exceeded. Now async since checkQuota is async.

Parameters
user

User | null | undefined

quotaKey

"ACTIVE_SCHEDULES" | "URL_FETCHES_PER_DAY" | "FILE_UPLOADS_PER_DAY" | "EVENTS_PER_IMPORT" | "TOTAL_EVENTS" | "IMPORT_JOBS_PER_DAY" | "FILE_SIZE_MB" | "CATALOGS_PER_USER"

amount?

number = 1

Returns

Promise<void>

checkAndIncrementUsage()

checkAndIncrementUsage(user, quotaKey, amount?, req?, throwOnExceeded?): Promise<boolean>

Atomically check quota and increment usage in a single SQL statement.

Eliminates the TOCTOU race between separate checkQuota() + incrementUsage() calls. The UPDATE only succeeds if the current value is below the limit, so concurrent requests cannot both slip through.

Parameters
user

User

quotaKey

"ACTIVE_SCHEDULES" | "URL_FETCHES_PER_DAY" | "FILE_UPLOADS_PER_DAY" | "EVENTS_PER_IMPORT" | "TOTAL_EVENTS" | "IMPORT_JOBS_PER_DAY" | "FILE_SIZE_MB" | "CATALOGS_PER_USER"

amount?

number = 1

req?

Partial<PayloadRequest>

throwOnExceeded?

boolean = true

Returns

Promise<boolean>

true if increment succeeded, false if quota would be exceeded

Throws

QuotaExceededError if quota exceeded and throwOnExceeded is true

getQuotaHeaders()

getQuotaHeaders(user, quotaKey?): Promise<Record<string, string>>

Get minimal quota headers for HTTP responses. Now async since checkQuota is async.

Security: Only returns operation-specific rate limit info, does not expose:

  • Trust levels (internal scoring system)
  • Detailed quotas across all types (system architecture)
  • Exact reset times (rate limiting strategy)
Parameters
user

User | null | undefined

quotaKey?

"ACTIVE_SCHEDULES" | "URL_FETCHES_PER_DAY" | "FILE_UPLOADS_PER_DAY" | "EVENTS_PER_IMPORT" | "TOTAL_EVENTS" | "IMPORT_JOBS_PER_DAY" | "FILE_SIZE_MB" | "CATALOGS_PER_USER"

Returns

Promise<Record<string, string>>

Interfaces

QuotaCheckResult

Result of a quota check operation.

Properties

allowed

allowed: boolean

current

current: number

limit

limit: number

remaining

remaining: number

resetTime?

optional resetTime: Date

quotaKey

quotaKey: "ACTIVE_SCHEDULES" | "URL_FETCHES_PER_DAY" | "FILE_UPLOADS_PER_DAY" | "EVENTS_PER_IMPORT" | "TOTAL_EVENTS" | "IMPORT_JOBS_PER_DAY" | "FILE_SIZE_MB" | "CATALOGS_PER_USER"

Functions

createQuotaService()

createQuotaService(payload): QuotaService

Create a quota service instance.

Returns a fresh instance each call. The service is stateless (all data lives in the database), so there is no benefit to caching the instance.

Parameters

payload

BasePayload

Returns

QuotaService

Last updated on