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-usagecollection - 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
Overrides
Error.constructor
Properties
stackTraceLimit
staticstackTraceLimit: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?
optionalcause:unknown
Inherited from
Error.cause
name
name:
string
Inherited from
Error.name
message
message:
string
Inherited from
Error.message
stack?
optionalstack: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?
optionalresetTime:Date
Methods
captureStackTrace()
staticcaptureStackTrace(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()
staticprepareStackTrace(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
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
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?
optionalresetTime: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