From 70010929107c2c31ed89753deb778875b6640b9e Mon Sep 17 00:00:00 2001 From: devfoma Date: Sun, 4 Oct 2026 08:39:30 +0100 Subject: [PATCH 1/3] feat(testing): add local deterministic fixture generator (#118) --- CONTRIBUTING.md | 12 + package.json | 3 +- scripts/generate-fixtures.ts | 57 ++++ .../fixtures/fixture-generator.spec.ts | 56 ++++ src/testing/fixtures/fixture-generator.ts | 303 ++++++++++++++++++ 5 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 scripts/generate-fixtures.ts create mode 100644 src/testing/fixtures/fixture-generator.spec.ts create mode 100644 src/testing/fixtures/fixture-generator.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b522387..2dcdc24 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,6 +109,18 @@ npm run sandbox:demo npm run test:sandbox ``` +### Deterministic Fixtures for Testing + +Contributors can generate seedable, repeatable test datasets covering standard user operations, edge cases, corrupt payloads, auth failure states, and boundary conditions. + +```bash +# Generate deterministic fixtures with default seed (42) to stdout +npm run fixtures:generate + +# Generate fixtures with custom seed and write output to file +npm run fixtures:generate -- --seed=12345 --output=test/fixtures/local.json +``` + Set `SANDBOX_MODE=true` in `.env` to enable it for the running API and select the processor per request with `X-Payment-Processor: sandbox` (or `PAYMENTS_DEFAULT_PROCESSOR=sandbox`). It is refused when diff --git a/package.json b/package.json index 2ad64ca..84d1401 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.config.ts", "migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.config.ts", "seed:audit": "ts-node src/seeds/seed-audit-data.ts", + "fixtures:generate": "ts-node -r tsconfig-paths/register scripts/generate-fixtures.ts", "docs:generate": "nest start --watch", "docs:serve": "nest start", "docs:build": "nest build && node dist/main.js", @@ -139,7 +140,7 @@ "winston": "^3.19.0", "winston-cloudwatch": "^6.3.0", "winston-daily-rotate-file": "^5.0.0", - "winston-transport": "^4.14.0" + "winston-transport": "^4.9.0" }, "devDependencies": { "@graphql-codegen/cli": "^7.2.0", diff --git a/scripts/generate-fixtures.ts b/scripts/generate-fixtures.ts new file mode 100644 index 0000000..07bd614 --- /dev/null +++ b/scripts/generate-fixtures.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env ts-node +/** + * CLI script to generate local deterministic fixtures for Trellis API testing. + * + * Usage: + * npx ts-node scripts/generate-fixtures.ts [--seed=42] [--output=filepath.json] [--pretty] + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { generateFixtures } from '../src/testing/fixtures/fixture-generator'; + +function parseArgs(): { seed: number; output?: string; pretty: boolean } { + const args = process.argv.slice(2); + let seed = 42; + let output: string | undefined; + let pretty = true; + + for (const arg of args) { + if (arg.startsWith('--seed=')) { + const parsedSeed = parseInt(arg.split('=')[1], 10); + if (!isNaN(parsedSeed)) { + seed = parsedSeed; + } + } else if (arg.startsWith('--output=')) { + output = arg.split('=')[1]; + } else if (arg === '--compact') { + pretty = false; + } + } + + return { seed, output, pretty }; +} + +function main(): void { + const options = parseArgs(); + const dataset = generateFixtures({ seed: options.seed }); + const jsonContent = options.pretty + ? JSON.stringify(dataset, null, 2) + : JSON.stringify(dataset); + + if (options.output) { + const targetPath = path.resolve(process.cwd(), options.output); + const parentDir = path.dirname(targetPath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + fs.writeFileSync(targetPath, jsonContent, 'utf-8'); + console.log(`[Fixtures] Deterministic dataset (seed=${options.seed}) written to ${targetPath}`); + } else { + console.log(jsonContent); + } +} + +if (require.main === module) { + main(); +} diff --git a/src/testing/fixtures/fixture-generator.spec.ts b/src/testing/fixtures/fixture-generator.spec.ts new file mode 100644 index 0000000..94b13bb --- /dev/null +++ b/src/testing/fixtures/fixture-generator.spec.ts @@ -0,0 +1,56 @@ +import { generateFixtures, SeededRandom } from './fixture-generator'; + +describe('Local Deterministic Fixture Generator (#118)', () => { + it('should generate stable deterministic output for the same seed across multiple runs', () => { + const seed = 12345; + const run1 = generateFixtures({ seed }); + const run2 = generateFixtures({ seed }); + + expect(run1).toEqual(run2); + expect(JSON.stringify(run1)).toBe(JSON.stringify(run2)); + }); + + it('should generate different outputs for different seeds', () => { + const runA = generateFixtures({ seed: 100 }); + const runB = generateFixtures({ seed: 200 }); + + expect(runA.scenarios.STANDARD_USER.user.userId).not.toBe( + runB.scenarios.STANDARD_USER.user.userId, + ); + }); + + it('should cover all five core scenarios', () => { + const dataset = generateFixtures({ seed: 42 }); + const scenarios = dataset.scenarios; + + expect(scenarios).toHaveProperty('STANDARD_USER'); + expect(scenarios).toHaveProperty('HIGH_VOLUME_OPERATOR'); + expect(scenarios).toHaveProperty('CORRUPT_PAYLOAD'); + expect(scenarios).toHaveProperty('AUTH_EXPIRED_OR_INVALID'); + expect(scenarios).toHaveProperty('UNINDEXED_OR_BOUNDARY_STATE'); + + expect(scenarios.STANDARD_USER.isValid).toBe(true); + expect(scenarios.HIGH_VOLUME_OPERATOR.isValid).toBe(true); + expect(scenarios.CORRUPT_PAYLOAD.isValid).toBe(false); + expect(scenarios.AUTH_EXPIRED_OR_INVALID.isValid).toBe(false); + expect(scenarios.UNINDEXED_OR_BOUNDARY_STATE.isValid).toBe(true); + }); + + it('should produce valid PRNG values using SeededRandom', () => { + const prng = new SeededRandom(777); + const val1 = prng.next(); + const val2 = prng.nextInt(1, 100); + const hex = prng.hex(8); + const uuid = prng.uuid(); + + expect(typeof val1).toBe('number'); + expect(val1).toBeGreaterThanOrEqual(0); + expect(val1).toBeLessThan(1); + expect(val2).toBeGreaterThanOrEqual(1); + expect(val2).toBeLessThanOrEqual(100); + expect(hex).toMatch(/^[0-9a-f]{8}$/); + expect(uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }); +}); diff --git a/src/testing/fixtures/fixture-generator.ts b/src/testing/fixtures/fixture-generator.ts new file mode 100644 index 0000000..9ceb0cc --- /dev/null +++ b/src/testing/fixtures/fixture-generator.ts @@ -0,0 +1,303 @@ +/** + * Deterministic fixture generator for Trellis API testing and local development. + * Produces stable, repeatable datasets modeling realistic Trellis users, records, + * operations, and failure edge cases based on a configurable PRNG seed. + */ + +export type ScenarioType = + | 'STANDARD_USER' + | 'HIGH_VOLUME_OPERATOR' + | 'CORRUPT_PAYLOAD' + | 'AUTH_EXPIRED_OR_INVALID' + | 'UNINDEXED_OR_BOUNDARY_STATE'; + +export interface UserFixture { + userId: string; + username: string; + email: string; + role: 'user' | 'operator' | 'admin'; + createdAt: string; + authHeader: string; + balanceStellar: string; +} + +export interface OperationRecordFixture { + operationId: string; + type: 'PAYMENT' | 'STAKE' | 'RECONCILE' | 'INDEX_SYNC'; + status: 'SUCCESS' | 'PENDING' | 'FAILED'; + amount: string; + timestamp: string; + payloadHash: string; +} + +export interface ScenarioFixture> { + scenario: ScenarioType; + description: string; + user: UserFixture; + operations: OperationRecordFixture[]; + metadata: T; + isValid: boolean; +} + +export interface FixtureDataset { + seed: number; + generatedAt: string; + scenarios: Record; +} + +export interface FixtureGeneratorOptions { + seed?: number | string; + scenarios?: ScenarioType[]; +} + +/** + * Seedable pseudo-random number generator (Mulberry32). + */ +export class SeededRandom { + private state: number; + + constructor(seed: number | string) { + if (typeof seed === 'string') { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = (hash << 5) - hash + seed.charCodeAt(i); + hash |= 0; + } + this.state = hash >>> 0; + } else { + this.state = seed >>> 0; + } + if (this.state === 0) { + this.state = 0x6d2b79f5; + } + } + + /** + * Returns a float between 0 (inclusive) and 1 (exclusive). + */ + next(): number { + let t = (this.state += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + /** + * Returns an integer between min (inclusive) and max (inclusive). + */ + nextInt(min: number, max: number): number { + return Math.floor(this.next() * (max - min + 1)) + min; + } + + /** + * Picks a deterministic item from an array. + */ + pick(array: T[]): T { + return array[this.nextInt(0, array.length - 1)]; + } + + /** + * Generates a deterministic hex string of specified length. + */ + hex(length: number): string { + const chars = '0123456789abcdef'; + let res = ''; + for (let i = 0; i < length; i++) { + res += chars[this.nextInt(0, 15)]; + } + return res; + } + + /** + * Generates a deterministic UUIDv4-formatted string. + */ + uuid(): string { + const r = () => this.hex(4); + return `${r()}${r()}-${r()}-4${r().substring(1)}-a${r().substring(1)}-${r()}${r()}${r()}`; + } +} + +/** + * Generates a deterministic fixture dataset for the given seed. + */ +export function generateFixtures(options: FixtureGeneratorOptions = {}): FixtureDataset { + const numericSeed = + typeof options.seed === 'number' + ? options.seed + : typeof options.seed === 'string' + ? options.seed.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) + : 42; + + const rng = new SeededRandom(numericSeed); + + const scenariosToGenerate: ScenarioType[] = options.scenarios || [ + 'STANDARD_USER', + 'HIGH_VOLUME_OPERATOR', + 'CORRUPT_PAYLOAD', + 'AUTH_EXPIRED_OR_INVALID', + 'UNINDEXED_OR_BOUNDARY_STATE', + ]; + + const scenarios: Partial> = {}; + + if (scenariosToGenerate.includes('STANDARD_USER')) { + const userId = rng.uuid(); + scenarios.STANDARD_USER = { + scenario: 'STANDARD_USER', + description: 'Standard active Trellis user with clean state and valid authorization', + isValid: true, + user: { + userId, + username: `user_${rng.hex(6)}`, + email: `dev_${rng.hex(4)}@trellis.example`, + role: 'user', + createdAt: new Date(1700000000000 + rng.nextInt(0, 10000000)).toISOString(), + authHeader: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${rng.hex(32)}`, + balanceStellar: (rng.nextInt(100, 5000) / 10).toFixed(2), + }, + operations: Array.from({ length: 3 }, (_, i) => ({ + operationId: rng.uuid(), + type: rng.pick(['PAYMENT', 'STAKE', 'RECONCILE']), + status: 'SUCCESS', + amount: (rng.nextInt(10, 500) / 10).toFixed(2), + timestamp: new Date(1705000000000 + i * 3600000).toISOString(), + payloadHash: rng.hex(32), + })), + metadata: { + kycStatus: 'VERIFIED', + tier: 'TIER_1', + rateLimitMax: 100, + }, + }; + } + + if (scenariosToGenerate.includes('HIGH_VOLUME_OPERATOR')) { + const userId = rng.uuid(); + scenarios.HIGH_VOLUME_OPERATOR = { + scenario: 'HIGH_VOLUME_OPERATOR', + description: 'High-frequency institutional operator with large transaction throughput and rate-limit edge states', + isValid: true, + user: { + userId, + username: `operator_${rng.hex(6)}`, + email: `ops_${rng.hex(4)}@institution.example`, + role: 'operator', + createdAt: new Date(1690000000000 + rng.nextInt(0, 5000000)).toISOString(), + authHeader: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${rng.hex(32)}`, + balanceStellar: (rng.nextInt(100000, 5000000) / 10).toFixed(2), + }, + operations: Array.from({ length: 10 }, (_, i) => ({ + operationId: rng.uuid(), + type: 'PAYMENT', + status: 'SUCCESS', + amount: (rng.nextInt(1000, 50000) / 10).toFixed(2), + timestamp: new Date(1705000000000 + i * 60000).toISOString(), + payloadHash: rng.hex(32), + })), + metadata: { + kycStatus: 'INSTITUTIONAL_VERIFIED', + tier: 'ENTERPRISE', + rateLimitMax: 10000, + activeApiKeys: 5, + quotaUsedPercent: 94.5, + }, + }; + } + + if (scenariosToGenerate.includes('CORRUPT_PAYLOAD')) { + const userId = rng.uuid(); + scenarios.CORRUPT_PAYLOAD = { + scenario: 'CORRUPT_PAYLOAD', + description: 'Malformed edge-case fixture with invalid field types and schema violations', + isValid: false, + user: { + userId: 'INVALID_UUID_FORMAT_!!!', + username: '', + email: 'invalid-email-format-without-at', + role: 'user', + createdAt: 'INVALID_DATE_STRING', + authHeader: 'Bearer corrupt_token_structure', + balanceStellar: '-99999.99999999', + }, + operations: [ + { + operationId: 'NOT_A_UUID', + type: 'INVALID_TYPE' as any, + status: 'FAILED', + amount: 'NaN', + timestamp: '2026-99-99T99:99:99Z', + payloadHash: 'CORRUPT_HASH', + }, + ], + metadata: { + errorReason: 'SCHEMA_VALIDATION_FAILURE', + corruptedFields: ['userId', 'email', 'createdAt', 'operations[0].amount'], + }, + }; + } + + if (scenariosToGenerate.includes('AUTH_EXPIRED_OR_INVALID')) { + const userId = rng.uuid(); + scenarios.AUTH_EXPIRED_OR_INVALID = { + scenario: 'AUTH_EXPIRED_OR_INVALID', + description: 'Security failure fixture modeling expired JWT, signature mismatch, and unauthorized scope', + isValid: false, + user: { + userId, + username: `unauth_${rng.hex(6)}`, + email: `revoked_${rng.hex(4)}@trellis.example`, + role: 'user', + createdAt: new Date(1680000000000).toISOString(), + authHeader: `Bearer expired_signature_${rng.hex(16)}`, + balanceStellar: '0.00', + }, + operations: [], + metadata: { + errorReason: 'UNAUTHORIZED_EXPIRED_TOKEN', + tokenExpiredAt: new Date(1690000000000).toISOString(), + attemptedEndpoint: '/api/v1/reconciliation/execute', + errorCode: 401, + }, + }; + } + + if (scenariosToGenerate.includes('UNINDEXED_OR_BOUNDARY_STATE')) { + const userId = rng.uuid(); + scenarios.UNINDEXED_OR_BOUNDARY_STATE = { + scenario: 'UNINDEXED_OR_BOUNDARY_STATE', + description: 'Boundary limit and unconfirmed protocol indexer state fixture', + isValid: true, + user: { + userId, + username: `boundary_${rng.hex(6)}`, + email: `edge_${rng.hex(4)}@trellis.example`, + role: 'user', + createdAt: new Date(1700000000000).toISOString(), + authHeader: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${rng.hex(32)}`, + balanceStellar: '9007199254740991.00', // MAX_SAFE_INTEGER + }, + operations: [ + { + operationId: rng.uuid(), + type: 'INDEX_SYNC', + status: 'PENDING', + amount: '0.00', + timestamp: new Date(1705000000000).toISOString(), + payloadHash: rng.hex(32), + }, + ], + metadata: { + indexerLagBlocks: 1420, + indexerStatus: 'SYNCING', + isBoundaryValue: true, + maxSafeIntegerBalance: true, + }, + }; + } + + return { + seed: numericSeed, + generatedAt: '2026-01-01T00:00:00.000Z', // fixed static timestamp string for strict determinism + scenarios: scenarios as Record, + }; +} From cd3ee47a8b3ae39949fc6e109a498e00fb533c04 Mon Sep 17 00:00:00 2001 From: devfoma Date: Sun, 4 Oct 2026 08:45:43 +0100 Subject: [PATCH 2/3] feat(events): add structured domain event schema with versioned consumers (#116) --- docs/DOMAIN_EVENTS.md | 32 +++ src/common/events/domain-event-schema.spec.ts | 116 +++++++++ src/common/events/domain-event-schema.ts | 225 ++++++++++++++++++ 3 files changed, 373 insertions(+) create mode 100644 docs/DOMAIN_EVENTS.md create mode 100644 src/common/events/domain-event-schema.spec.ts create mode 100644 src/common/events/domain-event-schema.ts diff --git a/docs/DOMAIN_EVENTS.md b/docs/DOMAIN_EVENTS.md new file mode 100644 index 0000000..395b9e6 --- /dev/null +++ b/docs/DOMAIN_EVENTS.md @@ -0,0 +1,32 @@ +# Trellis Domain Event Schemas & Versioned Consumers + +Trellis domain events enforce explicit, versioned payload schemas to ensure downstream consumers process events reliably without payload drift. + +## Architectural Overview + +- **Producer Validation**: Every event emitted via `DomainEventProducer` is validated against the `DomainEventRegistry` before being published to the transport. +- **Explicit Versioning**: All domain event payloads include an explicit `schemaVersion` string (e.g., `"1.0"`, `"2.0"`). +- **Versioned Consumers**: Consumers register handlers for specific schema versions and can declare a fallback handler to handle older or unmapped version payloads gracefully. + +## Event Payload Structure + +```json +{ + "eventId": "evt-12345678", + "eventName": "user.created", + "schemaVersion": "1.0", + "timestamp": "2026-10-04T08:00:00.000Z", + "producer": "trellis-api", + "payload": { + "userId": "usr_99887766", + "email": "user@example.com", + "role": "user" + } +} +``` + +## Consumer Compatibility Rules + +1. **Backwards Compatibility**: Minor schema additions maintain fallback compatibility. +2. **Version Registration**: Consumers should register handlers for versions they explicitly support (`registerHandler('event.name', '1.0', handler)`). +3. **Consumer Fallback**: When an unknown or older schema version is received, the consumer fallback handler (`registerFallbackHandler('event.name', fallbackFn)`) translates or handles the payload without raising fatal errors. diff --git a/src/common/events/domain-event-schema.spec.ts b/src/common/events/domain-event-schema.spec.ts new file mode 100644 index 0000000..6673e8b --- /dev/null +++ b/src/common/events/domain-event-schema.spec.ts @@ -0,0 +1,116 @@ +import { + DomainEventRegistry, + DomainEventProducer, + VersionedDomainEventConsumer, + DomainEventEnvelope, + DomainEventValidationError, + UnknownEventVersionError, +} from './domain-event-schema'; + +describe('Structured Domain Event Schema with Versioned Consumers (#116)', () => { + let registry: DomainEventRegistry; + let producer: DomainEventProducer; + let consumer: VersionedDomainEventConsumer; + + beforeEach(() => { + registry = DomainEventRegistry.getInstance(); + producer = new DomainEventProducer(registry); + consumer = new VersionedDomainEventConsumer(); + }); + + it('should validate and publish a valid domain event with explicit schema version', async () => { + const validEvent: DomainEventEnvelope = { + eventId: 'evt-101', + eventName: 'user.created', + schemaVersion: '1.0', + timestamp: new Date().toISOString(), + producer: 'user-service', + payload: { + userId: 'u-123', + email: 'alice@trellis.example', + role: 'user', + }, + }; + + const published = await producer.publish(validEvent); + expect(published).toBe(validEvent); + expect(published.schemaVersion).toBe('1.0'); + }); + + it('should reject event publishing when missing a required payload field (producer-side validation)', async () => { + const invalidEvent: DomainEventEnvelope = { + eventId: 'evt-102', + eventName: 'user.created', + schemaVersion: '1.0', + timestamp: new Date().toISOString(), + producer: 'user-service', + payload: { + userId: 'u-123', + // missing email and role + }, + }; + + await expect(producer.publish(invalidEvent)).rejects.toThrow( + DomainEventValidationError, + ); + }); + + it('should handle unknown version with UnknownEventVersionError', async () => { + const unknownVersionEvent: DomainEventEnvelope = { + eventId: 'evt-103', + eventName: 'user.created', + schemaVersion: '99.0', // non-existent version + timestamp: new Date().toISOString(), + producer: 'user-service', + payload: { + userId: 'u-123', + email: 'test@example.com', + role: 'user', + }, + }; + + await expect(producer.publish(unknownVersionEvent)).rejects.toThrow( + UnknownEventVersionError, + ); + }); + + it('should invoke exact version handler when available', async () => { + const v1Handler = jest.fn().mockReturnValue('v1_processed'); + consumer.registerHandler('user.created', '1.0', v1Handler); + + const event: DomainEventEnvelope = { + eventId: 'evt-104', + eventName: 'user.created', + schemaVersion: '1.0', + timestamp: new Date().toISOString(), + producer: 'user-service', + payload: { userId: 'u-1', email: 'a@b.com', role: 'user' }, + }; + + const res = await consumer.consume(event); + expect(res.handled).toBe(true); + expect(res.usedFallback).toBe(false); + expect(res.result).toBe('v1_processed'); + expect(v1Handler).toHaveBeenCalledWith(event); + }); + + it('should fallback to consumer fallback handler when specific version handler is missing', async () => { + const fallbackHandler = jest.fn().mockReturnValue('fallback_processed'); + consumer.registerFallbackHandler('user.created', fallbackHandler); + + const eventV2: DomainEventEnvelope = { + eventId: 'evt-105', + eventName: 'user.created', + schemaVersion: '2.0', + timestamp: new Date().toISOString(), + producer: 'user-service', + payload: { userId: 'u-2', email: 'b@b.com', role: 'user', kycTier: 'TIER_2' }, + }; + + const res = await consumer.consume(eventV2); + expect(res.handled).toBe(true); + expect(res.usedFallback).toBe(true); + expect(res.result).toBe('fallback_processed'); + expect(fallbackHandler).toHaveBeenCalledWith(eventV2); + }); +}); diff --git a/src/common/events/domain-event-schema.ts b/src/common/events/domain-event-schema.ts new file mode 100644 index 0000000..3c0fe9a --- /dev/null +++ b/src/common/events/domain-event-schema.ts @@ -0,0 +1,225 @@ +/** + * Structured Domain Event Schemas and Versioned Consumers for Trellis API (#116). + * Ensures stable payload schemas, producer-side validation, explicit schema versions, + * and consumer fallback mechanisms. + */ + +export interface DomainEventEnvelope { + eventId: string; + eventName: string; + schemaVersion: string; // Explicit version, e.g. "1.0", "2.0" + timestamp: string; + producer: string; + payload: T; +} + +export type FieldType = 'string' | 'number' | 'boolean' | 'object' | 'array'; + +export interface FieldDefinition { + name: string; + type: FieldType; + required: boolean; +} + +export interface DomainEventSchema { + eventName: string; + version: string; + fields: FieldDefinition[]; +} + +export class DomainEventValidationError extends Error { + constructor( + public readonly eventName: string, + public readonly schemaVersion: string, + public readonly missingOrInvalidFields: string[], + ) { + super( + `Domain event validation failed for '${eventName}' (v${schemaVersion}): ` + + `Invalid or missing fields: ${missingOrInvalidFields.join(', ')}`, + ); + this.name = 'DomainEventValidationError'; + } +} + +export class UnknownEventVersionError extends Error { + constructor( + public readonly eventName: string, + public readonly schemaVersion: string, + ) { + super( + `Unknown or unsupported schema version '${schemaVersion}' for event '${eventName}'.`, + ); + this.name = 'UnknownEventVersionError'; + } +} + +/** + * Registry holding event schemas and producer-side validation logic. + */ +export class DomainEventRegistry { + private static instance: DomainEventRegistry; + private schemas: Map> = new Map(); + + public static getInstance(): DomainEventRegistry { + if (!DomainEventRegistry.instance) { + DomainEventRegistry.instance = new DomainEventRegistry(); + DomainEventRegistry.instance.registerDefaultSchemas(); + } + return DomainEventRegistry.instance; + } + + public registerSchema(schema: DomainEventSchema): void { + if (!this.schemas.has(schema.eventName)) { + this.schemas.set(schema.eventName, new Map()); + } + this.schemas.get(schema.eventName)!.set(schema.version, schema); + } + + public getSchema(eventName: string, version: string): DomainEventSchema | undefined { + return this.schemas.get(eventName)?.get(version); + } + + public validateProducerEvent(event: DomainEventEnvelope): void { + if (!event.eventName || !event.schemaVersion || !event.payload) { + throw new DomainEventValidationError( + event.eventName || 'UNKNOWN', + event.schemaVersion || 'UNKNOWN', + ['envelope_structure'], + ); + } + + const schema = this.getSchema(event.eventName, event.schemaVersion); + if (!schema) { + throw new UnknownEventVersionError(event.eventName, event.schemaVersion); + } + + const missingOrInvalid: string[] = []; + + for (const field of schema.fields) { + const val = event.payload[field.name]; + + if (field.required && (val === undefined || val === null || val === '')) { + missingOrInvalid.push(field.name); + continue; + } + + if (val !== undefined && val !== null) { + if (field.type === 'array' && !Array.isArray(val)) { + missingOrInvalid.push(`${field.name} (expected array)`); + } else if (field.type !== 'array' && typeof val !== field.type) { + missingOrInvalid.push(`${field.name} (expected ${field.type})`); + } + } + } + + if (missingOrInvalid.length > 0) { + throw new DomainEventValidationError( + event.eventName, + event.schemaVersion, + missingOrInvalid, + ); + } + } + + private registerDefaultSchemas(): void { + // Default core event schemas + this.registerSchema({ + eventName: 'user.created', + version: '1.0', + fields: [ + { name: 'userId', type: 'string', required: true }, + { name: 'email', type: 'string', required: true }, + { name: 'role', type: 'string', required: true }, + ], + }); + + this.registerSchema({ + eventName: 'user.created', + version: '2.0', + fields: [ + { name: 'userId', type: 'string', required: true }, + { name: 'email', type: 'string', required: true }, + { name: 'role', type: 'string', required: true }, + { name: 'kycTier', type: 'string', required: true }, + ], + }); + + this.registerSchema({ + eventName: 'payment.completed', + version: '1.0', + fields: [ + { name: 'transactionId', type: 'string', required: true }, + { name: 'amount', type: 'number', required: true }, + { name: 'currency', type: 'string', required: true }, + ], + }); + } +} + +/** + * Producer class responsible for validating and publishing domain events. + */ +export class DomainEventProducer { + constructor( + private registry: DomainEventRegistry = DomainEventRegistry.getInstance(), + private publishTransport?: (event: DomainEventEnvelope) => Promise | void, + ) {} + + public async publish(event: DomainEventEnvelope): Promise { + // Producer-side schema validation + this.registry.validateProducerEvent(event); + + if (this.publishTransport) { + await this.publishTransport(event); + } + return event; + } +} + +export type EventHandler = (event: DomainEventEnvelope) => Promise | any; + +/** + * Versioned Consumer with fallback capabilities for older or drift versions. + */ +export class VersionedDomainEventConsumer { + private handlers: Map> = new Map(); + private fallbackHandlers: Map = new Map(); + + public registerHandler( + eventName: string, + schemaVersion: string, + handler: EventHandler, + ): void { + if (!this.handlers.has(eventName)) { + this.handlers.set(eventName, new Map()); + } + this.handlers.get(eventName)!.set(schemaVersion, handler); + } + + public registerFallbackHandler(eventName: string, handler: EventHandler): void { + this.fallbackHandlers.set(eventName, handler); + } + + public async consume(event: DomainEventEnvelope): Promise<{ + handled: boolean; + result: any; + usedFallback: boolean; + }> { + const versionMap = this.handlers.get(event.eventName); + const handler = versionMap?.get(event.schemaVersion); + + if (handler) { + const result = await handler(event); + return { handled: true, result, usedFallback: false }; + } + + // Try fallback handler if specific version handler is missing or unknown + const fallback = this.fallbackHandlers.get(event.eventName); + if (fallback) { + const result = await fallback(event); + return { handled: true, result, usedFallback: true }; + } + + throw new UnknownEventVersionError(event.eventName, event.schemaVersion); + } +} From be50c85e45d46ca65b5f11f12accebd01ce61328 Mon Sep 17 00:00:00 2001 From: devfoma Date: Sun, 4 Oct 2026 08:47:57 +0100 Subject: [PATCH 3/3] feat(config): add protocol configuration versioning and compatibility checks (#113) --- docs/PROTOCOL_VERSIONING.md | 27 ++++ src/config/protocol-config-versioning.spec.ts | 77 ++++++++++++ src/config/protocol-config-versioning.ts | 116 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 docs/PROTOCOL_VERSIONING.md create mode 100644 src/config/protocol-config-versioning.spec.ts create mode 100644 src/config/protocol-config-versioning.ts diff --git a/docs/PROTOCOL_VERSIONING.md b/docs/PROTOCOL_VERSIONING.md new file mode 100644 index 0000000..6053516 --- /dev/null +++ b/docs/PROTOCOL_VERSIONING.md @@ -0,0 +1,27 @@ +# Protocol Configuration Versioning & Compatibility + +Trellis API enforces strict semver compatibility rules on protocol configuration consumed across services, background jobs, and contracts. + +## Version Policy + +- **Current Version**: `1.2.0` +- **Minimum Supported Version**: `1.0.0` +- **Maximum Supported Version**: `1.99.99` + +## Compatibility Verification Flow + +Before initiating dependent operations (such as contract invocations, ledger sync, or batch processing), the system validates incoming protocol version metadata using `assertProtocolCompatibility(version)`. + +### Version Matrix + +| Version Range | Status | Result / Action | +| --- | --- | --- | +| `>= 1.0.0 <= 1.99.99` | **Compatible** | Execution proceeds cleanly | +| `< 1.0.0` | **Old Incompatible** | Fails early with `IncompatibleProtocolVersionError` (`OLD_INCOMPATIBLE`) | +| `> 1.99.99` (e.g. `2.0.0`) | **Future Unknown** | Fails early with `IncompatibleProtocolVersionError` (`FUTURE_UNKNOWN`) | +| `0.8.0`, `0.9.0` | **Deprecated** | Fails early with `IncompatibleProtocolVersionError` (`DEPRECATED`) | + +## Upgrade & Deprecation Guidelines + +1. **Upgrades**: Increment minor/patch versions for backwards-compatible changes. +2. **Deprecation**: Update `minSupportedVersion` and add retired versions to `deprecatedVersions` in `src/config/protocol-config-versioning.ts`. diff --git a/src/config/protocol-config-versioning.spec.ts b/src/config/protocol-config-versioning.spec.ts new file mode 100644 index 0000000..7dcdd24 --- /dev/null +++ b/src/config/protocol-config-versioning.spec.ts @@ -0,0 +1,77 @@ +import { + validateProtocolConfig, + assertProtocolCompatibility, + IncompatibleProtocolVersionError, + CURRENT_PROTOCOL_VERSION, + MIN_SUPPORTED_PROTOCOL_VERSION, + MAX_SUPPORTED_PROTOCOL_VERSION, +} from './protocol-config-versioning'; + +describe('Protocol Configuration Versioning & Compatibility Checks (#113)', () => { + it('should validate current version successfully without warnings', () => { + const res = validateProtocolConfig(CURRENT_PROTOCOL_VERSION); + + expect(res.compatible).toBe(true); + expect(res.version).toBe(CURRENT_PROTOCOL_VERSION); + expect(() => assertProtocolCompatibility(CURRENT_PROTOCOL_VERSION)).not.toThrow(); + }); + + it('should validate old-compatible version within supported range', () => { + const oldCompatibleVersion = '1.0.0'; + const res = validateProtocolConfig(oldCompatibleVersion); + + expect(res.compatible).toBe(true); + expect(res.version).toBe(oldCompatibleVersion); + expect(() => assertProtocolCompatibility(oldCompatibleVersion)).not.toThrow(); + }); + + it('should fail early with clear error for old-incompatible version below minSupportedVersion', () => { + const oldIncompatibleVersion = '0.5.0'; + + expect(() => validateProtocolConfig(oldIncompatibleVersion)).toThrow( + IncompatibleProtocolVersionError, + ); + + try { + assertProtocolCompatibility(oldIncompatibleVersion); + } catch (err: any) { + expect(err).toBeInstanceOf(IncompatibleProtocolVersionError); + expect(err.reason).toBe('OLD_INCOMPATIBLE'); + expect(err.message).toContain(`Minimum required version is '${MIN_SUPPORTED_PROTOCOL_VERSION}'`); + } + }); + + it('should fail early with clear error for future-unknown version exceeding maxSupportedVersion', () => { + const futureUnknownVersion = '2.0.0'; + + expect(() => validateProtocolConfig(futureUnknownVersion)).toThrow( + IncompatibleProtocolVersionError, + ); + + try { + assertProtocolCompatibility(futureUnknownVersion); + } catch (err: any) { + expect(err).toBeInstanceOf(IncompatibleProtocolVersionError); + expect(err.reason).toBe('FUTURE_UNKNOWN'); + expect(err.message).toContain(`Maximum supported version is '${MAX_SUPPORTED_PROTOCOL_VERSION}'`); + } + }); + + it('should fail early for deprecated versions', () => { + const deprecatedVersion = '0.9.0'; + + try { + validateProtocolConfig(deprecatedVersion); + } catch (err: any) { + expect(err).toBeInstanceOf(IncompatibleProtocolVersionError); + expect(err.reason).toBe('DEPRECATED'); + expect(err.message).toContain('is deprecated'); + } + }); + + it('should throw for invalid semver strings', () => { + expect(() => validateProtocolConfig('not-a-version')).toThrow( + IncompatibleProtocolVersionError, + ); + }); +}); diff --git a/src/config/protocol-config-versioning.ts b/src/config/protocol-config-versioning.ts new file mode 100644 index 0000000..95643bb --- /dev/null +++ b/src/config/protocol-config-versioning.ts @@ -0,0 +1,116 @@ +/** + * Protocol Configuration Versioning and Compatibility Checks (#113). + * Enforces semver-based compatibility validation for protocol configurations consumed by Trellis API. + * Fails early when encountering old-incompatible or future-unknown versions. + */ + +import * as semver from 'semver'; + +export const CURRENT_PROTOCOL_VERSION = '1.2.0'; +export const MIN_SUPPORTED_PROTOCOL_VERSION = '1.0.0'; +export const MAX_SUPPORTED_PROTOCOL_VERSION = '1.99.99'; + +export const DEPRECATED_PROTOCOL_VERSIONS = ['0.8.0', '0.9.0']; + +export interface ProtocolConfigMetadata { + configVersion: string; + minSupportedVersion: string; + maxSupportedVersion: string; + deprecatedVersions?: string[]; + protocolParams?: { + maxBatchSize?: number; + indexerPollIntervalMs?: number; + networkId?: string; + }; +} + +export const DEFAULT_PROTOCOL_CONFIG: ProtocolConfigMetadata = { + configVersion: CURRENT_PROTOCOL_VERSION, + minSupportedVersion: MIN_SUPPORTED_PROTOCOL_VERSION, + maxSupportedVersion: MAX_SUPPORTED_PROTOCOL_VERSION, + deprecatedVersions: DEPRECATED_PROTOCOL_VERSIONS, + protocolParams: { + maxBatchSize: 100, + indexerPollIntervalMs: 5000, + networkId: 'soroban-mainnet', + }, +}; + +export class IncompatibleProtocolVersionError extends Error { + constructor( + public readonly requestedVersion: string, + public readonly reason: 'OLD_INCOMPATIBLE' | 'FUTURE_UNKNOWN' | 'DEPRECATED' | 'INVALID_FORMAT', + message: string, + ) { + super(message); + this.name = 'IncompatibleProtocolVersionError'; + } +} + +export interface ProtocolValidationResult { + compatible: boolean; + version: string; + isDeprecated?: boolean; + warning?: string; +} + +/** + * Validates protocol configuration compatibility against system constraints. + */ +export function validateProtocolConfig( + requestedVersion: string, + config: ProtocolConfigMetadata = DEFAULT_PROTOCOL_CONFIG, +): ProtocolValidationResult { + const cleanVersion = semver.clean(requestedVersion); + + if (!cleanVersion || !semver.valid(cleanVersion)) { + throw new IncompatibleProtocolVersionError( + requestedVersion, + 'INVALID_FORMAT', + `Invalid protocol version format: '${requestedVersion}'. Must be valid semver (e.g. 1.2.0).`, + ); + } + + // Check if explicitly deprecated + if (config.deprecatedVersions && config.deprecatedVersions.includes(cleanVersion)) { + throw new IncompatibleProtocolVersionError( + cleanVersion, + 'DEPRECATED', + `Protocol version '${cleanVersion}' is deprecated and no longer supported. Please upgrade.`, + ); + } + + // Check if older than minimum supported version + if (semver.lt(cleanVersion, config.minSupportedVersion)) { + throw new IncompatibleProtocolVersionError( + cleanVersion, + 'OLD_INCOMPATIBLE', + `Protocol version '${cleanVersion}' is incompatible. Minimum required version is '${config.minSupportedVersion}'.`, + ); + } + + // Check if newer than max supported version or future major version + if (semver.gt(cleanVersion, config.maxSupportedVersion)) { + throw new IncompatibleProtocolVersionError( + cleanVersion, + 'FUTURE_UNKNOWN', + `Protocol version '${cleanVersion}' is unsupported (future unknown version). Maximum supported version is '${config.maxSupportedVersion}'.`, + ); + } + + return { + compatible: true, + version: cleanVersion, + }; +} + +/** + * Helper to assert protocol compatibility before executing dependent operations. + * Fails early with clear errors if incompatible. + */ +export function assertProtocolCompatibility( + incomingVersion: string, + config: ProtocolConfigMetadata = DEFAULT_PROTOCOL_CONFIG, +): void { + validateProtocolConfig(incomingVersion, config); +}