From 6192fbe8d2a5801d3dd2e0a7f7ebdc647b5dfcbc Mon Sep 17 00:00:00 2001 From: testersweb0-bug Date: Fri, 29 May 2026 21:59:23 +0100 Subject: [PATCH] Add creator docs, indexer latency logs, health timing test, and URL normalization. Consolidates creator field definitions into a reference doc, adds structured per-event indexer processing logs, an isolated health response-time integration test, and social link URL normalization before profile storage. --- CONTRIBUTING.md | 1 + README.md | 2 +- docs/architecture/creator-data-model.md | 219 ++++++++++++++++++ docs/architecture/domain-boundaries.md | 2 +- docs/indexer/EVENT_PROCESSING.md | 26 ++- src/constants/health.constants.ts | 8 + .../creator/creator-profile.schemas.ts | 7 +- .../creator/creator-profile.service.ts | 29 ++- .../creator-social-link-url.utils.test.ts | 29 +++ .../creator/creator-social-link-url.utils.ts | 47 ++++ .../health.response-time.integration.test.ts | 58 +++++ .../indexer-event-processor.utils.test.ts | 87 +++++++ src/utils/indexer-event-processor.utils.ts | 64 +++++ 13 files changed, 570 insertions(+), 9 deletions(-) create mode 100644 docs/architecture/creator-data-model.md create mode 100644 src/constants/health.constants.ts create mode 100644 src/modules/creator/creator-social-link-url.utils.test.ts create mode 100644 src/modules/creator/creator-social-link-url.utils.ts create mode 100644 src/modules/health/health.response-time.integration.test.ts create mode 100644 src/utils/indexer-event-processor.utils.test.ts create mode 100644 src/utils/indexer-event-processor.utils.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7653114..154519f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,7 @@ Thanks for contributing to the backend for Access Layer, a Stellar-native creato - Read the [README](./README.md) for context. - Review the [Backend Domain Model and Endpoint Boundaries](./docs/architecture/domain-boundaries.md). +- Review the [Creator Data Model Reference](./docs/architecture/creator-data-model.md) for field types and constraints. - Review the scoped backlog in [docs/open-source/issue-backlog.md](./docs/open-source/issue-backlog.md). - Keep pull requests limited to one backend issue or one documentation improvement. - Open a discussion before changing core API shape or background processing architecture. diff --git a/README.md b/README.md index ddad1c0..8e7c72b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The server is responsible for: - notifications, analytics, and moderation workflows - access checks for gated off-chain content -See [Backend Domain Model and Endpoint Boundaries](./docs/architecture/domain-boundaries.md) for a technical overview, [API Versioning](./docs/api-versioning.md) for details on schema versioning, [API Timeout Configuration](./docs/api-timeouts.md) for timeout defaults, and [Rate Limiting Configuration](./docs/rate-limiting.md) for rate limit defaults and guidelines. +See [Backend Domain Model and Endpoint Boundaries](./docs/architecture/domain-boundaries.md) for a technical overview, [Creator Data Model Reference](./docs/architecture/creator-data-model.md) for creator field definitions, [API Versioning](./docs/api-versioning.md) for details on schema versioning, [API Timeout Configuration](./docs/api-timeouts.md) for timeout defaults, and [Rate Limiting Configuration](./docs/rate-limiting.md) for rate limit defaults and guidelines. ## Tech diff --git a/docs/architecture/creator-data-model.md b/docs/architecture/creator-data-model.md new file mode 100644 index 0000000..8364b3e --- /dev/null +++ b/docs/architecture/creator-data-model.md @@ -0,0 +1,219 @@ +# Creator Data Model Reference + +This document is the canonical field-level reference for the creator data model. +It consolidates definitions spread across the Prisma schema, TypeScript types, +Zod validation schemas, and API response shapes. + +For module boundaries and high-level architecture, see +[Backend Domain Model and Endpoint Boundaries](./domain-boundaries.md). + +## Source files + +| Layer | Path | +| :------------------ | :--------------------------------------------------- | +| Database | `prisma/schema/creator.prisma` | +| TypeScript types | `src/types/profile.types.ts` | +| Profile API schemas | `src/modules/creator/creator-profile.schemas.ts` | +| List/update schemas | `src/modules/creators/creators.schemas.ts` | +| Admin metadata | `src/modules/admin/admin.controllers.ts` | +| List projection | `src/constants/creator-list-projection.constants.ts` | +| Detail projection | `src/constants/creator-detail-include.constants.ts` | +| Serializers | `src/modules/creators/creators.serializers.ts` | + +--- + +## Persisted fields (`CreatorProfile`) + +These fields are stored in the `CreatorProfile` Prisma model. + +| Field | Type | Required | Constraints | Purpose | +| :------------ | :--------- | :------- | :--------------------------------------------- | :----------------------------------------------------------- | +| `id` | `string` | yes | Primary key; `@default(cuid())` | Stable internal identifier for the creator profile | +| `userId` | `string` | yes | `@unique`; FK → `User.id`; `onDelete: Cascade` | Links the profile to exactly one user account | +| `handle` | `string` | yes | `@unique`; no format validation in Prisma | Public slug used in URLs and lookups | +| `displayName` | `string` | yes | No length constraint in Prisma | Human-readable name shown in list and detail views | +| `bio` | `string` | optional | Nullable | Short creator biography | +| `avatarUrl` | `string` | optional | Nullable; no format constraint in Prisma | URL to the creator's avatar image | +| `perkSummary` | `string` | optional | Nullable | Short summary of creator perks (legacy field) | +| `isVerified` | `boolean` | yes | `@default(false)` | Verification badge; updated via admin tools | +| `perks` | `Json` | optional | Nullable; unstructured JSON in DB | Structured perk list; validated as an array at the API layer | +| `createdAt` | `DateTime` | yes | `@default(now())` | Record creation timestamp | +| `updatedAt` | `DateTime` | yes | `@updatedAt` | Last modification timestamp | + +**Relationship:** Each `User` may have at most one `CreatorProfile` (`User.creatorProfile`). + +--- + +## Nested type: `CreatorPerk` + +Validated by `CreatorPerkSchema` in both profile and list schema modules. + +| Field | Type | Required | Constraints | Purpose | +| :------------ | :------- | :------- | :------------------------ | :------------------------------ | +| `id` | `string` | optional | CUID or UUID when present | Stable perk identifier | +| `title` | `string` | yes | 1–100 characters | Perk headline | +| `description` | `string` | yes | 1–500 characters | Perk detail text | +| `icon` | `string` | optional | — | Optional icon identifier or URL | + +--- + +## API-only fields (not yet persisted) + +These fields appear in API contracts but are **not** stored in the Prisma model today. + +| Field | Type | Required | Constraints | Purpose | +| :-------------- | :----------------- | :--------------------------------------------- | :----------------------------------------------------------------------------------- | :------------------------------------------------- | +| `links` | `{ label, url }[]` | optional (write); required (read, may be `[]`) | Max 8 links; label 1–40 chars; URL must be valid; URLs normalized before storage | Social and external profile links | +| `links[].label` | `string` | yes (when link present) | Trimmed; 1–40 characters | Platform or link name (e.g. `"twitter"`, `"site"`) | +| `links[].url` | `string` | yes (when link present) | Valid URL; trailing slashes removed; host lowercased; tracking query params stripped | Canonical social profile URL | + +--- + +## Write schemas + +### `UpsertCreatorProfileBodySchema` — `PUT /api/v1/creators/:creatorId/profile` + +Used by the profile upsert handler. All top-level fields are optional; at least +one field is typically supplied per request. + +| Field | Type | Required | Constraints | +| :------------ | :----------------- | :------- | :-------------------------------------- | +| `displayName` | `string` | optional | Trimmed; 2–80 characters | +| `bio` | `string` | optional | Trimmed; max 1000 characters | +| `avatarUrl` | `string` | optional | Trimmed; must be a valid URL | +| `links` | `{ label, url }[]` | optional | Max 8 items; see `links[]` fields above | +| `perks` | `CreatorPerk[]` | optional | Max 10 items | + +### `UpdateCreatorProfileSchema` — alternate update shape (legacy module) + +Defined in `src/modules/creators/creators.schemas.ts`. Uses `.strict()` — unknown +keys are rejected. + +| Field | Type | Required | Constraints | +| :------------ | :-------------- | :------- | :----------------------------- | +| `displayName` | `string` | optional | 1–100 characters | +| `bio` | `string` | optional | Max 1000 characters | +| `avatarUrl` | `string` | optional | Valid URL or empty string `''` | +| `perkSummary` | `string` | optional | Max 200 characters | +| `perks` | `CreatorPerk[]` | optional | — | + +### `CreateCreatorProfileDto` — registration (TypeScript type) + +Used when creating a new creator profile. No dedicated Zod schema exists yet. + +| Field | Type | Required | Constraints | +| :------------ | :-------------- | :----------- | :-------------------------------------------------------------------- | +| `handle` | `string` | **required** | Unique in DB; slug helpers exist but are not enforced at schema layer | +| `displayName` | `string` | **required** | — | +| `bio` | `string` | optional | — | +| `avatarUrl` | `string` | optional | — | +| `perkSummary` | `string` | optional | — | +| `perks` | `CreatorPerk[]` | optional | — | + +### Admin metadata update + +| Field | Type | Required | Constraints | +| :----------- | :-------- | :------- | :-------------------------------- | +| `isVerified` | `boolean` | optional | Updated via admin controller only | + +--- + +## Read response shapes + +### Profile read — `GET /api/v1/creators/:creatorId/profile` + +| Field | Type | Required | Notes | +| :--------------------------- | :---------------------------- | :------- | :---------------------------------- | +| `creatorId` | `string` | yes | Resolved profile ID | +| `displayName` | `string \| null` | yes | `null` when absent | +| `bio` | `string \| null` | yes | `null` when absent | +| `avatarUrl` | `string \| null` | yes | Valid URL or `null` | +| `perks` | `CreatorPerk[]` | optional | Omitted or empty when none | +| `links` | `{ label, url }[]` | yes | Empty array `[]` when none | +| `metadata.source` | `'placeholder' \| 'database'` | yes | Indicates data origin | +| `metadata.isProfileComplete` | `boolean` | yes | Derived from presence of key fields | + +### List item — `GET /api/v1/creators` + +Projected via `CREATOR_LIST_DEFAULT_SELECT`. + +| Field | Type | Required | Notes | +| :------------ | :-------- | :------- | :----------------------------------------- | +| `id` | `string` | yes | — | +| `handle` | `string` | yes | — | +| `displayName` | `string` | yes | Serialized as `name`; `null` when absent | +| `avatarUrl` | `string` | yes | Serialized as `avatar`; `null` when absent | +| `isVerified` | `boolean` | yes | — | + +### Detail stats — `PublicCreatorStats` + +Computed metrics, not stored on the profile record. + +| Field | Type | Required | Purpose | +| :--------------- | :------- | :------- | :---------------------------- | +| `holderCount` | `number` | yes | Number of key holders | +| `totalSupply` | `number` | yes | Total keys issued | +| `totalVolume` | `number` | yes | Cumulative trading volume | +| `lastActivityAt` | `Date` | optional | Most recent on-chain activity | + +--- + +## Query parameters — `GET /api/v1/creators` + +See [Creator List Query Parameter Precedence](../api/creator-list-query-precedence.md) +for full precedence rules. + +| Parameter | Type | Required | Default | Constraints | +| :--------- | :------ | :------- | :---------- | :------------------------------------------------------ | +| `limit` | integer | optional | `20` | 1–100 | +| `offset` | integer | optional | `0` | ≥ 0 | +| `sort` | enum | optional | `createdAt` | `createdAt`, `updatedAt`, `displayName`, `handle` | +| `order` | enum | optional | `desc` | `asc` or `desc` | +| `verified` | boolean | optional | absent | Filter by verification status | +| `search` | string | optional | absent | Trimmed and normalized; matches display name and handle | +| `include` | string | optional | absent | Comma-separated extra data (e.g. `stats`) | + +--- + +## Route parameters + +| Parameter | Schema | Required | Constraints | +| :---------- | :--------------------------- | :------- | :----------------------------------------------- | +| `creatorId` | `CreatorProfileParamsSchema` | yes | Trimmed; 1–128 characters; empty string rejected | + +--- + +## Required vs optional summary + +| Context | Required fields | Conditionally required | Optional fields | +| :------------------- | :------------------------------------ | :-------------------------------------------- | :------------------------------------------------------- | +| DB create (seed) | `userId`, `handle`, `displayName` | — | `bio`, `avatarUrl`, `perkSummary`, `perks`, `isVerified` | +| Profile upsert (PUT) | — (all fields optional) | Each supplied field must meet its constraints | `displayName`, `bio`, `avatarUrl`, `links`, `perks` | +| Profile create (DTO) | `handle`, `displayName` | — | `bio`, `avatarUrl`, `perkSummary`, `perks` | +| Perk object | `title`, `description` | — | `id`, `icon` | +| Link object | `label`, `url` (when link is present) | — | — | + +--- + +## Null vs absent conventions + +From `src/modules/creators/creators.serializers.ts`: + +- **List responses** use `null` for missing scalar fields (`name`, `avatar`). +- **Detail responses** use `null` for empty scalars and `[]` for empty collections. +- **Write payloads** omit fields the caller does not intend to change. + +--- + +## Known divergences + +These inconsistencies exist across layers and should be resolved in future +schema consolidation work: + +| Topic | Divergence | +| :----------------------- | :-------------------------------------------------------------------------------------------------- | +| `displayName` max length | Prisma: none; profile upsert: 80; legacy update schema: 100 | +| `handle` validation | DB `@unique` only; no Zod create schema; slug helpers in `src/utils/slug.utils.ts` are not enforced | +| `perkSummary` | Stored in Prisma; not in profile upsert schema | +| `links` | API contract only; not yet in Prisma model | +| `CreatorPerkSchema` | Duplicated in `creator-profile.schemas.ts` and `creators.schemas.ts` | diff --git a/docs/architecture/domain-boundaries.md b/docs/architecture/domain-boundaries.md index 93906e7..7676970 100644 --- a/docs/architecture/domain-boundaries.md +++ b/docs/architecture/domain-boundaries.md @@ -50,7 +50,7 @@ erDiagram ### Core Entities 1. **User**: Represents a registered user. Holds authentication and basic profile data. -2. **CreatorProfile**: Represents the creator persona of a user. Tied to a specific handle and contains metadata like bio and perks. +2. **CreatorProfile**: Represents the creator persona of a user. Tied to a specific handle and contains metadata like bio and perks. See [Creator Data Model Reference](./creator-data-model.md) for field-level types, constraints, and required/optional rules. 3. **StellarWallet**: Links a user to their Stellar public address. Used for identity verification and ownership checks. 4. **IndexerDLQ**: Stores failed indexing jobs from the Stellar blockchain for manual review or reprocessing. 5. **AuditEvent**: A generic log for significant actions occurring in the system. diff --git a/docs/indexer/EVENT_PROCESSING.md b/docs/indexer/EVENT_PROCESSING.md index 01b86c2..6c16a11 100644 --- a/docs/indexer/EVENT_PROCESSING.md +++ b/docs/indexer/EVENT_PROCESSING.md @@ -12,10 +12,15 @@ The `dedupeChainEvents` helper in `src/utils/indexer-dedupe.utils.ts` provides t ```typescript import { dedupeChainEvents } from '../utils/indexer-dedupe.utils'; +import { processIndexerChainEvents } from '../utils/indexer-event-processor.utils'; const rawEvents = fetchEventsFromChain(); const uniqueEvents = dedupeChainEvents(rawEvents); -// Proceed with processing uniqueEvents + +// Process each event with structured latency logging +await processIndexerChainEvents(uniqueEvents, async event => { + await handleChainEvent(event); +}); ``` ## 2. Idempotency @@ -28,6 +33,25 @@ Event handlers must be idempotent. This means that processing the same event mul - **State Check**: Before applying a change (like incrementing a balance), verify if the event has already been accounted for (e.g. by checking a `lastProcessedLedger` or a specific event log). - **Atomic Transactions**: Ensure that all changes related to an event are committed in a single database transaction. +## 4. Structured latency logging + +Each processed chain event emits one info-level structured log via +`processIndexerChainEvent` in `src/utils/indexer-event-processor.utils.ts`. + +The log includes: + +| Field | Description | +| :----------- | :--------------------------------------------------- | +| `type` | Always `indexer_event_processed` | +| `eventType` | Domain event type (e.g. `CREATOR_REGISTERED`) | +| `eventId` | Stable identifier: `{txHash}:{eventIndex}` | +| `txHash` | Transaction hash | +| `eventIndex` | Event index within the transaction | +| `ledger` | Block/ledger number when available | +| `elapsedMs` | Processing duration from handler start to completion | + +Use `processIndexerChainEvents` to dedupe a batch and log once per unique event. + ## 3. Error Handling If an event fails to process after multiple retries, it is moved to the [Dead-Letter Queue (DLQ)](./DLQ_WORKFLOW.md) for manual investigation. diff --git a/src/constants/health.constants.ts b/src/constants/health.constants.ts new file mode 100644 index 0000000..086657c --- /dev/null +++ b/src/constants/health.constants.ts @@ -0,0 +1,8 @@ +/** + * Maximum acceptable response time for the liveness health endpoint + * (`GET /api/v1/health`) in the test environment. + * + * The budget is intentionally generous to avoid flaky CI while still catching + * regressions where heavy work is accidentally added to the hot path. + */ +export const HEALTH_LIVENESS_MAX_LATENCY_MS = 100; diff --git a/src/modules/creator/creator-profile.schemas.ts b/src/modules/creator/creator-profile.schemas.ts index 423f57f..d223dde 100644 --- a/src/modules/creator/creator-profile.schemas.ts +++ b/src/modules/creator/creator-profile.schemas.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { withCreatorSlugEmptyStringNormalization } from './creator-slug-input.utils'; +import { normalizeSocialLinkUrl } from './creator-social-link-url.utils'; /** * Shared creator profile identifier schema for route params. @@ -80,7 +81,11 @@ export const UpsertCreatorProfileBodySchema = z.object({ .trim() .min(1, 'Link label is required') .max(40, 'Link label must be at most 40 characters'), - url: z.string().trim().url('Link URL must be a valid URL'), + url: z + .string() + .trim() + .url('Link URL must be a valid URL') + .transform(normalizeSocialLinkUrl), }) ) .max(8, 'At most 8 profile links are allowed') diff --git a/src/modules/creator/creator-profile.service.ts b/src/modules/creator/creator-profile.service.ts index 7d0d32b..2f5d7be 100644 --- a/src/modules/creator/creator-profile.service.ts +++ b/src/modules/creator/creator-profile.service.ts @@ -5,6 +5,20 @@ import { UpsertCreatorProfileBody, } from './creator-profile.schemas'; import { CREATOR_DETAIL_DEFAULT_SELECT } from '../../constants/creator-detail-include.constants'; +import { normalizeSocialLinkUrl } from './creator-social-link-url.utils'; + +function normalizeProfileLinks( + links: UpsertCreatorProfileBody['links'] +): UpsertCreatorProfileBody['links'] { + if (!links) { + return links; + } + + return links.map((link) => ({ + ...link, + url: normalizeSocialLinkUrl(link.url), + })); +} function buildCreatorDetailCacheMissContext(creatorId: string) { return { @@ -81,21 +95,26 @@ export async function upsertCreatorProfile( acceptedProfile: UpsertCreatorProfileBody; metadata: { source: 'database'; persisted: boolean }; }> { + const normalizedPayload: UpsertCreatorProfileBody = { + ...payload, + links: normalizeProfileLinks(payload.links), + }; + const profile = await prisma.creatorProfile.update({ where: { id: creatorId, }, data: { - displayName: payload.displayName, - bio: payload.bio, - avatarUrl: payload.avatarUrl, - perks: payload.perks as any, + displayName: normalizedPayload.displayName, + bio: normalizedPayload.bio, + avatarUrl: normalizedPayload.avatarUrl, + perks: normalizedPayload.perks as any, }, }); return { creatorId: profile.id, - acceptedProfile: payload, + acceptedProfile: normalizedPayload, metadata: { source: 'database', persisted: true, diff --git a/src/modules/creator/creator-social-link-url.utils.test.ts b/src/modules/creator/creator-social-link-url.utils.test.ts new file mode 100644 index 0000000..7abbcf4 --- /dev/null +++ b/src/modules/creator/creator-social-link-url.utils.test.ts @@ -0,0 +1,29 @@ +import { normalizeSocialLinkUrl } from './creator-social-link-url.utils'; + +describe('normalizeSocialLinkUrl', () => { + it('removes trailing slashes from the path', () => { + expect(normalizeSocialLinkUrl('https://example.com/alice/')).toBe( + 'https://example.com/alice' + ); + }); + + it('lowercases the host component', () => { + expect(normalizeSocialLinkUrl('https://Twitter.com/Alice')).toBe( + 'https://twitter.com/Alice' + ); + }); + + it('strips tracking query parameters', () => { + expect( + normalizeSocialLinkUrl( + 'https://example.com/alice?utm_source=twitter&utm_medium=social&page=1' + ) + ).toBe('https://example.com/alice?page=1'); + }); + + it('preserves the root path slash', () => { + expect(normalizeSocialLinkUrl('https://Example.com/')).toBe( + 'https://example.com/' + ); + }); +}); diff --git a/src/modules/creator/creator-social-link-url.utils.ts b/src/modules/creator/creator-social-link-url.utils.ts new file mode 100644 index 0000000..5dac015 --- /dev/null +++ b/src/modules/creator/creator-social-link-url.utils.ts @@ -0,0 +1,47 @@ +/** + * Query parameters commonly used for click tracking that should be stripped + * before storing social profile URLs. + */ +const TRACKING_QUERY_PARAMS = new Set([ + 'fbclid', + 'gclid', + 'mc_cid', + 'mc_eid', + 'msclkid', + 'ref', + 'ref_src', + 'ref_url', + 'utm_campaign', + 'utm_content', + 'utm_medium', + 'utm_source', + 'utm_term', +]); + +/** + * Normalizes a social profile URL for consistent storage and comparison. + * + * - Removes trailing slashes from the path (preserves root `/`) + * - Lowercases the host component + * - Strips common tracking query parameters + */ +export function normalizeSocialLinkUrl(raw: string): string { + const trimmed = raw.trim(); + const parsed = new URL(trimmed); + + parsed.hostname = parsed.hostname.toLowerCase(); + + for (const param of [...parsed.searchParams.keys()]) { + if (TRACKING_QUERY_PARAMS.has(param.toLowerCase())) { + parsed.searchParams.delete(param); + } + } + + let normalized = parsed.toString(); + + if (parsed.pathname !== '/' && normalized.endsWith('/')) { + normalized = normalized.replace(/\/+$/, ''); + } + + return normalized; +} diff --git a/src/modules/health/health.response-time.integration.test.ts b/src/modules/health/health.response-time.integration.test.ts new file mode 100644 index 0000000..28033fb --- /dev/null +++ b/src/modules/health/health.response-time.integration.test.ts @@ -0,0 +1,58 @@ +// Isolated integration test for health endpoint response time. +// Run alone to avoid interference from concurrent test activity: +// pnpm exec jest src/modules/health/health.response-time.integration.test.ts + +jest.mock('../../config', () => ({ + envConfig: { + MODE: 'test', + PORT: 3000, + INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: 300000, + }, + appConfig: { + allowedOrigins: [], + }, +})); + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + $queryRaw: jest.fn(), + }, +})); + +import { Request, Response } from 'express'; +import { simpleHealthCheck } from './health.controllers'; +import { HEALTH_LIVENESS_MAX_LATENCY_MS } from '../../constants/health.constants'; +import { elapsedMs, startTimer } from '../../utils/monotonic-clock.utils'; + +function mockResponse(): Response & { statusCode: number; body: unknown } { + const res = { statusCode: 0, body: undefined as unknown } as Response & { + statusCode: number; + body: unknown; + }; + res.status = (code: number) => { + res.statusCode = code; + return res; + }; + res.json = (payload: unknown) => { + res.body = payload; + return res; + }; + return res; +} + +describe('simpleHealthCheck() — response time budget', () => { + it(`responds within ${HEALTH_LIVENESS_MAX_LATENCY_MS}ms`, () => { + const timer = startTimer(); + const res = mockResponse(); + + simpleHealthCheck({} as Request, res); + + const durationMs = elapsedMs(timer); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ success: true, message: 'OK' }) + ); + expect(durationMs).toBeLessThanOrEqual(HEALTH_LIVENESS_MAX_LATENCY_MS); + }); +}); diff --git a/src/utils/indexer-event-processor.utils.test.ts b/src/utils/indexer-event-processor.utils.test.ts new file mode 100644 index 0000000..94ae3be --- /dev/null +++ b/src/utils/indexer-event-processor.utils.test.ts @@ -0,0 +1,87 @@ +import { logger } from './logger.utils'; +import { + getChainEventId, + processIndexerChainEvent, + processIndexerChainEvents, + IndexerChainEvent, +} from './indexer-event-processor.utils'; + +jest.mock('./logger.utils', () => ({ + logger: { + info: jest.fn(), + }, +})); + +const infoMock = logger.info as jest.Mock; + +function makeEvent(overrides: Partial = {}): IndexerChainEvent { + return { + txHash: '0xabc123', + eventIndex: 0, + eventType: 'CREATOR_REGISTERED', + ...overrides, + }; +} + +describe('indexer-event-processor.utils', () => { + beforeEach(() => { + infoMock.mockClear(); + }); + + describe('getChainEventId', () => { + it('combines txHash and eventIndex', () => { + expect(getChainEventId(makeEvent({ txHash: '0xdead', eventIndex: 3 }))).toBe( + '0xdead:3' + ); + }); + }); + + describe('processIndexerChainEvent', () => { + it('emits one structured log after the handler completes', async () => { + const event = makeEvent(); + const handler = jest.fn().mockResolvedValue(undefined); + + await processIndexerChainEvent(event, handler); + + expect(handler).toHaveBeenCalledWith(event); + expect(infoMock).toHaveBeenCalledTimes(1); + expect(infoMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'indexer_event_processed', + eventType: 'CREATOR_REGISTERED', + eventId: '0xabc123:0', + txHash: '0xabc123', + eventIndex: 0, + elapsedMs: expect.any(Number), + }), + 'Indexer chain event processed' + ); + }); + + it('does not emit a log when the handler throws', async () => { + const handler = jest.fn().mockRejectedValue(new Error('handler failed')); + + await expect( + processIndexerChainEvent(makeEvent(), handler) + ).rejects.toThrow('handler failed'); + + expect(infoMock).not.toHaveBeenCalled(); + }); + }); + + describe('processIndexerChainEvents', () => { + it('dedupes events and logs once per unique event', async () => { + const events: IndexerChainEvent[] = [ + makeEvent({ txHash: '0x1', eventIndex: 0, eventType: 'KEY_BOUGHT' }), + makeEvent({ txHash: '0x1', eventIndex: 0, eventType: 'KEY_BOUGHT' }), + makeEvent({ txHash: '0x1', eventIndex: 1, eventType: 'KEY_SOLD' }), + ]; + const handler = jest.fn().mockResolvedValue(undefined); + + await processIndexerChainEvents(events, handler); + + expect(handler).toHaveBeenCalledTimes(2); + expect(infoMock).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/utils/indexer-event-processor.utils.ts b/src/utils/indexer-event-processor.utils.ts new file mode 100644 index 0000000..125810b --- /dev/null +++ b/src/utils/indexer-event-processor.utils.ts @@ -0,0 +1,64 @@ +import { logger } from './logger.utils'; +import { dedupeChainEvents, ChainEvent } from './indexer-dedupe.utils'; +import { elapsedMs, startTimer } from './monotonic-clock.utils'; + +/** + * Minimal chain event shape required for indexer processing and logging. + */ +export interface IndexerChainEvent extends ChainEvent { + /** Domain event type (e.g. CREATOR_REGISTERED, KEY_BOUGHT). */ + eventType: string; +} + +/** + * Stable identifier for a chain event, used for deduplication and log correlation. + */ +export function getChainEventId(event: ChainEvent): string { + return `${event.txHash}:${event.eventIndex}`; +} + +/** + * Processes a single chain event with timing and structured logging. + * + * Emits exactly one info-level log after the handler completes successfully. + * Latency is measured from the start of processing to completion using a + * monotonic clock. + */ +export async function processIndexerChainEvent( + event: T, + handler: (event: T) => Promise +): Promise { + const timer = startTimer(); + const eventId = getChainEventId(event); + + await handler(event); + + logger.info( + { + type: 'indexer_event_processed', + eventType: event.eventType, + eventId, + txHash: event.txHash, + eventIndex: event.eventIndex, + ledger: event.ledger, + elapsedMs: elapsedMs(timer), + }, + 'Indexer chain event processed' + ); +} + +/** + * Dedupes a batch of chain events and processes each unique event sequentially. + * + * Each event emits one structured log entry via {@link processIndexerChainEvent}. + */ +export async function processIndexerChainEvents( + events: T[], + handler: (event: T) => Promise +): Promise { + const uniqueEvents = dedupeChainEvents(events); + + for (const event of uniqueEvents) { + await processIndexerChainEvent(event, handler); + } +}