Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/api-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Public bootstrap configuration.

Public creator discovery and stats endpoints.

- Request lifecycle reference: [`docs/creator-request-lifecycle.md`](./creator-request-lifecycle.md).

| Method | Path | Description |
| :----- | :-------------------- | :------------------------------------------- |
| `GET` | `/creators` | List creators with pagination and filtering. |
Expand Down
38 changes: 38 additions & 0 deletions docs/creator-request-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Creator Request Lifecycle

This reference describes the request flow for the public creator routes mounted
under `/api/v1/creators`.

## Route registration

- `src/modules/creators/creators.routes.ts` registers the public creator list
and stats routes.
- `src/modules/creator/creator.routes.ts` keeps the legacy scaffolded creator
routes aligned with the same handler conventions.

## Request order

1. The request enters the creator router.
2. `normalizeTrailingSlash` removes an optional trailing slash so the same
handler serves `/api/v1/creators` and `/api/v1/creators/`.
3. `createCreatorReadMetricsMiddleware(...)` starts request timing and records
success/error counts when the response finishes.
4. `cacheControl(...)` applies the public cache headers for GET responses.
5. The controller builds a request context with the raw query object.
6. The controller logs unexpected sort fields at debug/warn level without
mutating the request payload used for validation.
7. `parsePublicQuery(...)` validates and normalizes the query parameters.
8. `fetchCreatorList(...)` builds the Prisma filter and either serves a cached
response or queries the database.
9. `serializeCreatorListResponse(...)` wraps the list and pagination metadata.
10. `attachTimestampHeader(...)` adds the response timestamp header.
11. `sendSuccess(...)` writes the JSON response body.

## Validation and logging points

- Validation happens inside the controller before the database query runs.
- Cache lookup and cache hit/miss ratio logging happen in the list service.
- Query normalization debug snapshots are emitted only when debug logging is
enabled.
- The cache-key helper percent-encodes special characters before the key is
logged or used for cache storage.
2 changes: 2 additions & 0 deletions src/modules/creator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ All routes are mounted under `/api/v1/creators`.
- Authentication and authorization are intentionally deferred.
- Persistence/indexing integration is intentionally deferred.
- Current handlers are designed so storage/indexing can be added without changing route contracts.
- The request lifecycle for the public creator routes is documented in
[`docs/creator-request-lifecycle.md`](../../docs/creator-request-lifecycle.md).
84 changes: 46 additions & 38 deletions src/modules/creators/creators-cache-key.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
import { CreatorListQueryType } from './creators.schemas';
import { buildCanonicalParamString } from '../../utils/cache-key-params.utils';
import { encodeCreatorListQueryStringValue } from './creators.query-string.utils';

/**
* Builds a cache key for the creator feed endpoint.
Expand All @@ -32,21 +33,24 @@ import { buildCanonicalParamString } from '../../utils/cache-key-params.utils';
* ```
*/
export function buildCreatorFeedCacheKey(query: CreatorListQueryType): string {
const params: Record<string, string | number | boolean | undefined> = {
limit: query.limit,
offset: query.offset,
sort: query.sort,
order: query.order,
verified: query.verified,
search: query.search !== '' ? query.search : undefined,
include:
query.include !== undefined && query.include.length > 0
? query.include.join(',')
: undefined,
};
const params: Record<string, string | number | boolean | undefined> = {
limit: query.limit,
offset: query.offset,
sort: query.sort,
order: query.order,
verified: query.verified,
search:
query.search !== ''
? encodeCreatorListQueryStringValue(query.search)
: undefined,
include:
query.include !== undefined && query.include.length > 0
? query.include.join(',')
: undefined,
};

const canonical = buildCanonicalParamString(params);
return canonical ? `creators:${canonical}` : 'creators';
const canonical = buildCanonicalParamString(params);
return canonical ? `creators:${canonical}` : 'creators';
}

/**
Expand All @@ -68,11 +72,11 @@ export function buildCreatorFeedCacheKey(query: CreatorListQueryType): string {
* creator or filter combinations.
*/
export const CREATOR_FEED_CACHE_INVALIDATION_TOUCHPOINTS = {
CREATOR_REGISTERED: 'creator:registered',
CREATOR_PROFILE_UPDATED: 'creator:profile:updated',
CREATOR_VERIFICATION_CHANGED: 'creator:verification:changed',
CREATOR_KEYS_UPDATED: 'creator:keys:updated',
CREATOR_STATS_UPDATED: 'creator:stats:updated',
CREATOR_REGISTERED: 'creator:registered',
CREATOR_PROFILE_UPDATED: 'creator:profile:updated',
CREATOR_VERIFICATION_CHANGED: 'creator:verification:changed',
CREATOR_KEYS_UPDATED: 'creator:keys:updated',
CREATOR_STATS_UPDATED: 'creator:stats:updated',
} as const;

/**
Expand All @@ -91,11 +95,13 @@ export const CREATOR_FEED_CACHE_INVALIDATION_TOUCHPOINTS = {
* // Returns: ['creators:*:*:*:*:*:*:*'] (all creator feed entries)
* ```
*/
export function buildCreatorFeedInvalidationKeys(_creatorId?: string): string[] {
// Since the creator feed includes all creators and supports various filters,
// we invalidate all creator feed entries when any creator changes.
// This is a conservative approach that ensures cache consistency.
return ['creators:*'];
export function buildCreatorFeedInvalidationKeys(
_creatorId?: string
): string[] {
// Since the creator feed includes all creators and supports various filters,
// we invalidate all creator feed entries when any creator changes.
// This is a conservative approach that ensures cache consistency.
return ['creators:*'];
}

/**
Expand All @@ -114,23 +120,25 @@ export function buildCreatorFeedInvalidationKeys(_creatorId?: string): string[]
* ```
*/
export function buildCreatorFeedFilterInvalidationKeys(filters: {
verified?: boolean;
search?: string;
verified?: boolean;
search?: string;
}): string[] {
const patterns: string[] = [];
const patterns: string[] = [];

if (filters.verified !== undefined) {
patterns.push(`creators:*:*:*:*:verified:${filters.verified}:*`);
}
if (filters.verified !== undefined) {
patterns.push(`creators:*:*:*:*:verified:${filters.verified}:*`);
}

if (filters.search !== undefined) {
patterns.push(`creators:*:*:*:*:search:${filters.search}:*`);
}
if (filters.search !== undefined) {
patterns.push(
`creators:*:*:*:*:search:${encodeCreatorListQueryStringValue(filters.search) ?? filters.search}:*`
);
}

// If no specific filters, invalidate all
if (patterns.length === 0) {
return ['creators:*'];
}
// If no specific filters, invalidate all
if (patterns.length === 0) {
return ['creators:*'];
}

return patterns;
return patterns;
}
140 changes: 140 additions & 0 deletions src/modules/creators/creators.cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { CREATOR_PUBLIC_ROUTE_CACHE_MAX_AGE_SECONDS } from '../../constants/creator-public-cache.constants';
import { logger } from '../../utils/logger.utils';
import { CreatorProfile } from '../../types/profile.types';
import { CreatorListQueryType } from './creators.schemas';
import { buildCreatorFeedCacheKey } from './creators-cache-key.utils';

type CreatorListCacheEntry = {
creators: CreatorProfile[];
total: number;
expiresAt: number;
};

type CreatorListCacheStats = {
hits: number;
misses: number;
};

const creatorListCache = new Map<string, CreatorListCacheEntry>();
const creatorListCacheStats: CreatorListCacheStats = {
hits: 0,
misses: 0,
};
const MAX_CREATOR_LIST_CACHE_ENTRIES = 250;

function getCreatorListCacheTtlMs(): number {
return CREATOR_PUBLIC_ROUTE_CACHE_MAX_AGE_SECONDS.publicRead * 1000;
}

function pruneCreatorListCache(now: number): void {
for (const [cacheKey, entry] of creatorListCache.entries()) {
if (entry.expiresAt <= now) {
creatorListCache.delete(cacheKey);
}
}

if (creatorListCache.size <= MAX_CREATOR_LIST_CACHE_ENTRIES) {
return;
}

const overflow = creatorListCache.size - MAX_CREATOR_LIST_CACHE_ENTRIES;
const oldestEntries = [...creatorListCache.entries()]
.sort((left, right) => left[1].expiresAt - right[1].expiresAt)
.slice(0, overflow);

for (const [cacheKey] of oldestEntries) {
creatorListCache.delete(cacheKey);
}
}

function logCreatorListCacheLookup(input: {
cacheKey: string;
hit: boolean;
query: CreatorListQueryType;
}): void {
if (!logger.isLevelEnabled('debug')) {
return;
}

const totalLookups =
creatorListCacheStats.hits + creatorListCacheStats.misses;
const hitRatio =
totalLookups === 0 ? 0 : creatorListCacheStats.hits / totalLookups;

logger.debug({
msg: 'Creator list cache lookup',
event: 'creator_list_cache_lookup',
cacheKey: input.cacheKey,
cacheHit: input.hit,
cacheHits: creatorListCacheStats.hits,
cacheMisses: creatorListCacheStats.misses,
cacheHitRatio: hitRatio,
ttlMs: getCreatorListCacheTtlMs(),
limit: input.query.limit,
offset: input.query.offset,
sort: input.query.sort,
order: input.query.order,
hasSearch: input.query.search !== undefined,
hasVerifiedFilter: input.query.verified !== undefined,
});
}

export function getCachedCreatorList(
query: CreatorListQueryType
): { creators: CreatorProfile[]; total: number } | null {
const cacheKey = buildCreatorFeedCacheKey(query);
const cachedEntry = creatorListCache.get(cacheKey);
const now = Date.now();

if (cachedEntry && cachedEntry.expiresAt > now) {
creatorListCacheStats.hits += 1;
logCreatorListCacheLookup({
cacheKey,
hit: true,
query,
});

return {
creators: [...cachedEntry.creators],
total: cachedEntry.total,
};
}

if (cachedEntry) {
creatorListCache.delete(cacheKey);
}

pruneCreatorListCache(now);

creatorListCacheStats.misses += 1;
logCreatorListCacheLookup({
cacheKey,
hit: false,
query,
});

return null;
}

export function setCachedCreatorList(
query: CreatorListQueryType,
creators: CreatorProfile[],
total: number
): void {
const cacheKey = buildCreatorFeedCacheKey(query);
const now = Date.now();

creatorListCache.set(cacheKey, {
creators: [...creators],
total,
expiresAt: now + getCreatorListCacheTtlMs(),
});

pruneCreatorListCache(now);
}

export function resetCreatorListCache(): void {
creatorListCache.clear();
creatorListCacheStats.hits = 0;
creatorListCacheStats.misses = 0;
}
46 changes: 46 additions & 0 deletions src/modules/creators/creators.query-string.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,49 @@ export function withCreatorListQueryStringNormalization<T extends ZodTypeAny>(
) {
return z.preprocess(normalizeCreatorListQueryStringValue, schema);
}

const UNRESERVED_QUERY_VALUE_PATTERN = /^[A-Za-z0-9._~-]$/;
const HEX_PAIR_PATTERN = /^[0-9A-Fa-f]{2}$/;

/**
* Percent-encodes a creator query string value for safe logging or forwarding.
*
* Existing percent-encoded sequences are preserved as-is so already encoded
* values are not double-encoded.
*/
export function encodeCreatorListQueryStringValue(
value: string | null | undefined
): string | undefined {
if (typeof value !== 'string') {
return undefined;
}

let encoded = '';

for (let index = 0; index < value.length; ) {
const currentChar = value[index];

if (
currentChar === '%' &&
index + 2 < value.length &&
HEX_PAIR_PATTERN.test(value.slice(index + 1, index + 3))
) {
encoded += value.slice(index, index + 3);
index += 3;
continue;
}

const codePoint = value.codePointAt(index);
const char = String.fromCodePoint(codePoint ?? 0);

if (UNRESERVED_QUERY_VALUE_PATTERN.test(char)) {
encoded += char;
} else {
encoded += encodeURIComponent(char);
}

index += char.length;
}

return encoded;
}
12 changes: 9 additions & 3 deletions src/modules/creators/creators.sort-field.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import {
CREATOR_LIST_SORT_FIELDS,
type CreatorListSortField,
} from '../../constants/creator-list-sort.constants';
import { normalizeCreatorListQueryStringValue } from './creators.query-string.utils';
import {
encodeCreatorListQueryStringValue,
normalizeCreatorListQueryStringValue,
} from './creators.query-string.utils';
import { logger } from '../../utils/logger.utils';

/**
Expand Down Expand Up @@ -45,13 +48,16 @@ export function warnIfUnrecognizedCreatorListSort(
}

const normalized = normalizeCreatorListQueryStringValue(rawSort);
if (typeof normalized !== 'string' || isRecognizedCreatorListSortField(normalized)) {
if (
typeof normalized !== 'string' ||
isRecognizedCreatorListSortField(normalized)
) {
return;
}

logger.warn({
msg: 'Unrecognized creator list sort field',
sort: normalized,
sort: encodeCreatorListQueryStringValue(normalized) ?? normalized,
...(requestId ? { requestId } : {}),
});
}
Loading
Loading