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
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions docs/DOMAIN_EVENTS.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions docs/PROTOCOL_VERSIONING.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
57 changes: 57 additions & 0 deletions scripts/generate-fixtures.ts
Original file line number Diff line number Diff line change
@@ -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();
}
116 changes: 116 additions & 0 deletions src/common/events/domain-event-schema.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading