From 414683645163005795963bf0eb28f769d8977d75 Mon Sep 17 00:00:00 2001 From: coderolisa Date: Sat, 17 Oct 2026 13:32:12 +0100 Subject: [PATCH] Add event compatibility reference example - Created reference Soroban contract demonstrating v1 and v2 event schemas - Implemented TypeScript decoder with backward compatibility handling - Added comprehensive fixtures for v1, v2, and mixed event streams - Included automated tests verifying compatibility guarantees - Documented compatibility decisions and integration guidelines --- examples/event-compatibility/README.md | 268 ++++++++++++++++ .../__tests__/compatibility.test.ts | 290 ++++++++++++++++++ .../fixtures/mixed-stream.json | 71 +++++ .../fixtures/v1-notification-created.json | 12 + .../fixtures/v2-notification-created.json | 15 + .../listener-decoder/jest.config.js | 0 .../listener-decoder/package.json | 34 ++ .../listener-decoder/src/event-decoder.ts | 241 +++++++++++++++ .../listener-decoder/tsconfig.json | 19 ++ .../reference-contract/Cargo.toml | 27 ++ .../reference-contract/src/lib.rs | 278 +++++++++++++++++ 11 files changed, 1255 insertions(+) create mode 100644 examples/event-compatibility/README.md create mode 100644 examples/event-compatibility/listener-decoder/__tests__/compatibility.test.ts create mode 100644 examples/event-compatibility/listener-decoder/fixtures/mixed-stream.json create mode 100644 examples/event-compatibility/listener-decoder/fixtures/v1-notification-created.json create mode 100644 examples/event-compatibility/listener-decoder/fixtures/v2-notification-created.json create mode 100644 examples/event-compatibility/listener-decoder/jest.config.js create mode 100644 examples/event-compatibility/listener-decoder/package.json create mode 100644 examples/event-compatibility/listener-decoder/src/event-decoder.ts create mode 100644 examples/event-compatibility/listener-decoder/tsconfig.json create mode 100644 examples/event-compatibility/reference-contract/Cargo.toml create mode 100644 examples/event-compatibility/reference-contract/src/lib.rs diff --git a/examples/event-compatibility/README.md b/examples/event-compatibility/README.md new file mode 100644 index 00000000..1ecaef8e --- /dev/null +++ b/examples/event-compatibility/README.md @@ -0,0 +1,268 @@ +# Event Compatibility Reference Example + +This example demonstrates how to introduce new event types in the NotifyChain ecosystem without breaking existing consumers (listeners, indexers, and third-party integrations). + +## Overview + +Event schema evolution is inevitable as the platform grows. This reference shows **backward-compatible event versioning patterns** that preserve the existing event stream while adding new capabilities. + +## Core Compatibility Principle + +> **Additive changes only**: New fields are appended as trailing topics or added to the data payload. Existing topic positions and data structure remain unchanged. + +## What's Included + +### 1. Reference Contract (`reference-contract.rs`) + +A minimal Soroban contract demonstrating: +- `NotificationCreatedV1` — original event with 3 topics +- `NotificationCreatedV2` — enhanced event with an extra trailing topic + +Both events are emitted side-by-side to show coexistence. + +### 2. Listener Decoder (`event-decoder.ts`) + +TypeScript decoder showing: +- **Graceful degradation**: consumers that only read the first 3 topics continue working unchanged +- **Progressive enhancement**: new consumers can opt into reading the 4th topic +- **Version detection**: how to distinguish v1 from v2 payloads + +### 3. Event Fixtures (`fixtures/`) + +- `v1-notification-created.json` — sample RPC event for v1 schema +- `v2-notification-created.json` — sample RPC event for v2 schema (with extra topic) +- `mixed-stream.json` — a realistic event stream containing both versions + +### 4. Compatibility Tests (`compatibility.test.ts`) + +Automated verification that: +- v1 consumers can process v2 events (ignoring the new field) +- v2 consumers can process v1 events (with sensible defaults) +- the decoder never throws on either version + +## Running the Example + +### Prerequisites + +- Rust 1.75+ with `wasm32-unknown-unknown` target +- Node.js 18+ and npm +- Stellar CLI (`stellar` or `soroban`) + +### Compile the Contract + +```bash +cd reference-contract +cargo build --target wasm32-unknown-unknown --release +``` + +The WASM artifact is in `target/wasm32-unknown-unknown/release/event_compatibility_reference.wasm`. + +### Run the Tests + +```bash +cd listener-decoder +npm install +npm test +``` + +All tests should pass, proving that both old and new fixtures are decoded correctly. + +## Compatibility Decisions Explained + +### Decision 1: Trailing Topics for Indexable Fields + +**Why trailing?** + +Stellar RPC and most indexers match on topic positions. If you insert a new topic in the middle, existing queries break: + +```rust +// ❌ BREAKS existing listeners that read topic[1] expecting `creator` +event.publish((symbol_short!("NotifV2"), priority, creator, id)); + +// ✅ SAFE: existing listeners still find `creator` at topic[1] +event.publish((symbol_short!("NotifV2"), creator, id, priority)); +``` + +**Rule**: Always append new indexed fields as the **last topic**. + +### Decision 2: Extend Data Payload for Complex Fields + +Topics are limited to simple types (Address, Symbol, u32, BytesN). For structured data, extend the `value` map: + +```rust +// v1 payload (Map) +let data_v1 = map![ + &env, + (symbol_short!("msg"), message), +]; + +// v2 payload (same map + new keys) +let data_v2 = map![ + &env, + (symbol_short!("msg"), message), + (symbol_short!("retry"), retry_count), // ✅ new field +]; +``` + +Older decoders ignore unknown keys. Newer decoders check for their presence and use defaults when missing. + +### Decision 3: Payload Version Field + +Every versioned event carries a `payload_version` field in its data map: + +```rust +let data = map![ + &env, + (symbol_short!("version"), 2_u32), // explicit version marker + // ... rest of fields +]; +``` + +**Why?** + +- Lets decoders branch on schema version without guessing +- Future-proofs against incompatible changes (if you ever need v3 with a different structure) +- Aligns with Soroban's `ScVal` introspection capabilities + +**Default**: Events without a `payload_version` field are treated as **v1** for backward compatibility. + +### Decision 4: Event Name Stays the Same + +You might be tempted to publish `NotificationCreatedV2` as a new event name. **Don't.** + +```rust +// ❌ Creates a separate event stream; existing consumers miss these entirely +env.events().publish((symbol_short!("NotifV2"), ...), ...); + +// ✅ Same event name; consumers see all notifications +env.events().publish((symbol_short!("NotifCrtd"), ...), ...); +``` + +Use the `payload_version` field to distinguish schemas within the same event name. + +**Exception**: If the semantic meaning changes significantly (e.g., `GroupCreated` vs. `GroupUpdated`), use a new event name. For schema evolution of the *same* lifecycle event, keep the name. + +### Decision 5: Decoder Fallback Strategy + +The TypeScript decoder demonstrates **three-tier fallback**: + +1. **Optimistic decode**: try parsing as the latest version +2. **Fallback to v1**: if a field is missing, use the v1 schema +3. **Validation**: reject only if *required* v1 fields are also missing + +```typescript +function decodeNotificationCreated(event: RpcEvent): Notification { + const version = event.value.payload_version ?? 1; // default to v1 + + if (version === 2) { + return { + creator: event.topics[1], + id: event.topics[2], + priority: event.topics[3], // ✅ new field + message: event.value.msg, + }; + } + + // v1 path + return { + creator: event.topics[1], + id: event.topics[2], + priority: 'medium', // ✅ sensible default for missing field + message: event.value.msg, + }; +} +``` + +This pattern ensures: +- **Zero downtime**: deploy the contract upgrade first, then update the listener +- **Gradual rollout**: old listeners keep running while new ones are tested +- **No event loss**: the stream is continuous across the migration + +## Integration with NotifyChain Listener + +The NotifyChain listener already implements this pattern for all AutoShare events. See: + +- `listener/src/services/event-normalization.ts` — version detection and normalization +- `listener/src/utils/event-utils.ts` — `parseEventVersion` and validation +- `CONTRACT_EVENT_REFERENCE.md` — documented schema for all event types + +When adding a **new event type**: + +1. Define the v1 schema in the contract +2. Emit it with `payload_version: 1` in the data map +3. Add the event name to `SUPPORTED_EVENT_NAMES` in `event-utils.ts` +4. Write a fixture in `listener/src/test-utils/notification-fixture-builder.ts` +5. Add an entry to `CONTRACT_EVENT_REFERENCE.md` + +When **evolving an existing event**: + +1. Increment `payload_version` to 2 +2. Append new topics to the end +3. Add new keys to the data map (don't remove old ones) +4. Update the decoder to handle both versions +5. Add a v2 fixture and update the reference doc + +## Testing Your Own Event Changes + +Use the provided fixtures as templates: + +```bash +# Test v1 compatibility +npm test -- --testNamePattern="processes v1 events" + +# Test v2 compatibility +npm test -- --testNamePattern="processes v2 events" + +# Test mixed stream (realistic scenario) +npm test -- --testNamePattern="handles mixed version stream" +``` + +Fixtures are JSON files that mirror the structure returned by `stellar rpc getEvents`. Copy `v1-notification-created.json`, modify the fields, and the test suite will validate your changes. + +## Common Pitfalls + +### ❌ Removing Fields + +```rust +// v1 +let data_v1 = map![&env, (symbol_short!("msg"), message), (symbol_short!("sender"), sender)]; + +// v2 — BREAKS v1 consumers expecting `sender` +let data_v2 = map![&env, (symbol_short!("msg"), message)]; +``` + +**Fix**: Mark fields as deprecated in docs, but keep emitting them. Remove only in a major version (v3+). + +### ❌ Changing Topic Order + +```rust +// v1 +event.publish((symbol_short!("Notif"), creator, id), data); + +// v2 — BREAKS existing indexers querying topic[1] for creator +event.publish((symbol_short!("Notif"), id, creator), data); +``` + +**Fix**: Never reorder topics. Add new ones at the end. + +### ❌ Changing Field Types + +```rust +// v1: id is BytesN<32> +let id: BytesN<32> = ...; + +// v2: id is String — BREAKS all existing decoders +let id: String = "notif-12345".into(); +``` + +**Fix**: Introduce a new field name (`id_string`) and keep `id` as-is. + +## Resources + +- [NotifyChain Event Reference](../../CONTRACT_EVENT_REFERENCE.md) — all supported events +- [Notification Lifecycle](../../NOTIFICATION_LIFECYCLE.md) — end-to-end flow +- [Soroban Events Guide](https://soroban.stellar.org/docs/learn/events) — official docs + +## License + +MIT — same as the NotifyChain project. diff --git a/examples/event-compatibility/listener-decoder/__tests__/compatibility.test.ts b/examples/event-compatibility/listener-decoder/__tests__/compatibility.test.ts new file mode 100644 index 00000000..33abaccd --- /dev/null +++ b/examples/event-compatibility/listener-decoder/__tests__/compatibility.test.ts @@ -0,0 +1,290 @@ +/** + * Compatibility Test Suite + * + * Validates that the event decoder handles both v1 and v2 events correctly, + * ensuring backward compatibility during schema evolution. + */ + +import { + decodeNotificationCreated, + decodeBatch, + normalizeToV2, + isV2Notification, + validateEventVersion, + getEventStats, + type DecodedEvent, + type NotificationV1, + type NotificationV2, +} from '../src/event-decoder'; + +// Import fixtures +import v1Event from '../fixtures/v1-notification-created.json'; +import v2Event from '../fixtures/v2-notification-created.json'; +import mixedStream from '../fixtures/mixed-stream.json'; + +describe('Event Compatibility - V1 Events', () => { + it('should decode v1 event with correct structure', () => { + const notification = decodeNotificationCreated(v1Event as DecodedEvent); + + expect(notification.version).toBe(1); + expect(notification.creator).toBe( + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4' + ); + expect(notification.notificationId).toBe( + '0101010101010101010101010101010101010101010101010101010101010101' + ); + expect(notification.message).toBe('Test notification v1 - legacy format'); + }); + + it('should handle v1 events without throwing on missing v2 fields', () => { + expect(() => { + decodeNotificationCreated(v1Event as DecodedEvent); + }).not.toThrow(); + }); + + it('should mark v1 notification correctly', () => { + const notification = decodeNotificationCreated(v1Event as DecodedEvent); + expect(isV2Notification(notification)).toBe(false); + }); + + it('should validate v1 event version correctly', () => { + expect(validateEventVersion(v1Event as DecodedEvent, 1)).toBe(true); + expect(validateEventVersion(v1Event as DecodedEvent, 2)).toBe(false); + }); +}); + +describe('Event Compatibility - V2 Events', () => { + it('should decode v2 event with all enhanced fields', () => { + const notification = decodeNotificationCreated( + v2Event as DecodedEvent + ) as NotificationV2; + + expect(notification.version).toBe(2); + expect(notification.creator).toBe( + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBSC5' + ); + expect(notification.notificationId).toBe( + '0202020202020202020202020202020202020202020202020202020202020202' + ); + expect(notification.message).toBe('Test notification v2 - enhanced format'); + expect(notification.priority).toBe('high'); + expect(notification.category).toBe('alert'); + expect(notification.retryCount).toBe(0); + }); + + it('should extract priority from topic[3] in v2 events', () => { + const notification = decodeNotificationCreated( + v2Event as DecodedEvent + ) as NotificationV2; + expect(notification.priority).toBe('high'); + }); + + it('should mark v2 notification correctly', () => { + const notification = decodeNotificationCreated(v2Event as DecodedEvent); + expect(isV2Notification(notification)).toBe(true); + }); + + it('should validate v2 event version correctly', () => { + expect(validateEventVersion(v2Event as DecodedEvent, 2)).toBe(true); + expect(validateEventVersion(v2Event as DecodedEvent, 1)).toBe(false); + }); +}); + +describe('Event Compatibility - Mixed Stream', () => { + it('should process mixed v1/v2 stream without errors', () => { + const notifications = decodeBatch(mixedStream as DecodedEvent[]); + + expect(notifications).toHaveLength(5); + expect(notifications.filter((n) => n.version === 1)).toHaveLength(2); + expect(notifications.filter((n) => n.version === 2)).toHaveLength(3); + }); + + it('should maintain correct order when processing mixed stream', () => { + const notifications = decodeBatch(mixedStream as DecodedEvent[]); + + expect(notifications[0].message).toContain('First notification'); + expect(notifications[1].message).toContain('Second notification'); + expect(notifications[2].message).toContain('Third notification'); + expect(notifications[3].message).toContain('Fourth notification'); + expect(notifications[4].message).toContain('Fifth notification'); + }); + + it('should correctly identify version distribution in mixed stream', () => { + const stats = getEventStats(mixedStream as DecodedEvent[]); + + expect(stats.total).toBe(5); + expect(stats.v1Count).toBe(2); + expect(stats.v2Count).toBe(3); + expect(stats.unknownCount).toBe(0); + }); +}); + +describe('Event Compatibility - Normalization', () => { + it('should normalize v1 to v2 with default values', () => { + const v1Notification = decodeNotificationCreated( + v1Event as DecodedEvent + ) as NotificationV1; + const normalized = normalizeToV2(v1Notification); + + expect(normalized.version).toBe(2); + expect(normalized.priority).toBe('medium'); // Default + expect(normalized.category).toBe('general'); // Default + expect(normalized.retryCount).toBe(0); // Default + expect(normalized.message).toBe(v1Notification.message); // Preserved + }); + + it('should pass through v2 unchanged during normalization', () => { + const v2Notification = decodeNotificationCreated( + v2Event as DecodedEvent + ) as NotificationV2; + const normalized = normalizeToV2(v2Notification); + + expect(normalized).toEqual(v2Notification); + }); + + it('should allow downstream code to work with uniform v2 interface', () => { + const v1Notification = decodeNotificationCreated( + v1Event as DecodedEvent + ) as NotificationV1; + const v2Notification = decodeNotificationCreated( + v2Event as DecodedEvent + ) as NotificationV2; + + const normalized1 = normalizeToV2(v1Notification); + const normalized2 = normalizeToV2(v2Notification); + + // Both now have the same shape + expect(normalized1.priority).toBeDefined(); + expect(normalized2.priority).toBeDefined(); + expect(normalized1.category).toBeDefined(); + expect(normalized2.category).toBeDefined(); + }); +}); + +describe('Event Compatibility - Error Handling', () => { + it('should throw on missing required topics', () => { + const invalidEvent = { + eventName: 'NotifCrtd', + topics: ['NotifCrtd', 'GAAAA...'], // Only 2 topics, need at least 3 + data: { message: 'Test' }, + }; + + expect(() => { + decodeNotificationCreated(invalidEvent as DecodedEvent); + }).toThrow('expected at least 3 topics'); + }); + + it('should throw on missing required message field', () => { + const invalidEvent = { + eventName: 'NotifCrtd', + topics: ['NotifCrtd', 'GAAAA...', 'notif-123'], + data: {}, // Missing message + }; + + expect(() => { + decodeNotificationCreated(invalidEvent as DecodedEvent); + }).toThrow('Missing required field: message'); + }); + + it('should collect errors when batch processing with invalid events', () => { + const mixedWithError = [ + v1Event, + { + eventName: 'NotifCrtd', + topics: ['NotifCrtd'], // Invalid: too few topics + data: { message: 'Test' }, + }, + v2Event, + ]; + + // Should not throw, but log warnings + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const notifications = decodeBatch(mixedWithError as DecodedEvent[]); + + expect(notifications).toHaveLength(2); // Only valid ones + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to decode'), + expect.any(Array) + ); + + consoleSpy.mockRestore(); + }); +}); + +describe('Event Compatibility - Realistic Scenarios', () => { + it('should handle gradual migration where v2 contract emits v1-compatible events', () => { + // Simulate a v2 contract that still emits v1-shaped events for legacy consumers + const v2ContractV1Event = { + eventName: 'NotifCrtd', + topics: [ + 'NotifCrtd', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + '0101010101010101010101010101010101010101010101010101010101010101', + ], + data: { + payload_version: 1, // Explicitly marked as v1 + message: 'Legacy consumer compatibility event', + }, + }; + + const notification = decodeNotificationCreated( + v2ContractV1Event as DecodedEvent + ); + expect(notification.version).toBe(1); + }); + + it('should handle rollback scenario where v2 listener receives v1 events after downgrade', () => { + // A listener built for v2 should gracefully handle v1 events + const notifications = decodeBatch([ + v2Event as DecodedEvent, + v1Event as DecodedEvent, // Older event appears after upgrade + v2Event as DecodedEvent, + ]); + + expect(notifications).toHaveLength(3); + expect(notifications[0].version).toBe(2); + expect(notifications[1].version).toBe(1); + expect(notifications[2].version).toBe(2); + }); + + it('should process events from parallel contract instances with different versions', () => { + // Two contract instances: one still on v1, another upgraded to v2 + const parallelStream = [ + { ...v1Event, contractId: 'contract-instance-A' }, + { ...v2Event, contractId: 'contract-instance-B' }, + { ...v1Event, contractId: 'contract-instance-A' }, + { ...v2Event, contractId: 'contract-instance-B' }, + ]; + + const notifications = decodeBatch( + parallelStream as unknown as DecodedEvent[] + ); + + expect(notifications).toHaveLength(4); + const v1Count = notifications.filter((n) => n.version === 1).length; + const v2Count = notifications.filter((n) => n.version === 2).length; + + expect(v1Count).toBe(2); + expect(v2Count).toBe(2); + }); +}); + +describe('Event Compatibility - Performance', () => { + it('should handle large batches efficiently', () => { + const largeBatch: DecodedEvent[] = []; + for (let i = 0; i < 1000; i++) { + largeBatch.push( + i % 2 === 0 + ? (v1Event as DecodedEvent) + : (v2Event as DecodedEvent) + ); + } + + const start = Date.now(); + const notifications = decodeBatch(largeBatch); + const duration = Date.now() - start; + + expect(notifications).toHaveLength(1000); + expect(duration).toBeLessThan(1000); // Should process 1000 events in under 1 second + }); +}); diff --git a/examples/event-compatibility/listener-decoder/fixtures/mixed-stream.json b/examples/event-compatibility/listener-decoder/fixtures/mixed-stream.json new file mode 100644 index 00000000..f3c26fa7 --- /dev/null +++ b/examples/event-compatibility/listener-decoder/fixtures/mixed-stream.json @@ -0,0 +1,71 @@ +[ + { + "eventName": "NotifCrtd", + "topics": [ + "NotifCrtd", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + "0101010101010101010101010101010101010101010101010101010101010101" + ], + "data": { + "payload_version": 1, + "message": "First notification - v1 format" + } + }, + { + "eventName": "NotifCrtd", + "topics": [ + "NotifCrtd", + "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBSC5", + "0202020202020202020202020202020202020202020202020202020202020202", + "high" + ], + "data": { + "payload_version": 2, + "message": "Second notification - v2 format with priority", + "retry_count": 0, + "category": "alert" + } + }, + { + "eventName": "NotifCrtd", + "topics": [ + "NotifCrtd", + "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCSC6", + "0303030303030303030303030303030303030303030303030303030303030303" + ], + "data": { + "payload_version": 1, + "message": "Third notification - another v1" + } + }, + { + "eventName": "NotifCrtd", + "topics": [ + "NotifCrtd", + "GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDSC7", + "0404040404040404040404040404040404040404040404040404040404040404", + "critical" + ], + "data": { + "payload_version": 2, + "message": "Fourth notification - critical priority v2", + "retry_count": 3, + "category": "system" + } + }, + { + "eventName": "NotifCrtd", + "topics": [ + "NotifCrtd", + "GEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEESC8", + "0505050505050505050505050505050505050505050505050505050505050505", + "low" + ], + "data": { + "payload_version": 2, + "message": "Fifth notification - low priority informational", + "retry_count": 0, + "category": "info" + } + } +] diff --git a/examples/event-compatibility/listener-decoder/fixtures/v1-notification-created.json b/examples/event-compatibility/listener-decoder/fixtures/v1-notification-created.json new file mode 100644 index 00000000..063c01f8 --- /dev/null +++ b/examples/event-compatibility/listener-decoder/fixtures/v1-notification-created.json @@ -0,0 +1,12 @@ +{ + "eventName": "NotifCrtd", + "topics": [ + "NotifCrtd", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + "0101010101010101010101010101010101010101010101010101010101010101" + ], + "data": { + "payload_version": 1, + "message": "Test notification v1 - legacy format" + } +} diff --git a/examples/event-compatibility/listener-decoder/fixtures/v2-notification-created.json b/examples/event-compatibility/listener-decoder/fixtures/v2-notification-created.json new file mode 100644 index 00000000..43a0bb6f --- /dev/null +++ b/examples/event-compatibility/listener-decoder/fixtures/v2-notification-created.json @@ -0,0 +1,15 @@ +{ + "eventName": "NotifCrtd", + "topics": [ + "NotifCrtd", + "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBSC5", + "0202020202020202020202020202020202020202020202020202020202020202", + "high" + ], + "data": { + "payload_version": 2, + "message": "Test notification v2 - enhanced format", + "retry_count": 0, + "category": "alert" + } +} diff --git a/examples/event-compatibility/listener-decoder/jest.config.js b/examples/event-compatibility/listener-decoder/jest.config.js new file mode 100644 index 00000000..e69de29b diff --git a/examples/event-compatibility/listener-decoder/package.json b/examples/event-compatibility/listener-decoder/package.json new file mode 100644 index 00000000..904de384 --- /dev/null +++ b/examples/event-compatibility/listener-decoder/package.json @@ -0,0 +1,34 @@ +{ + "name": "event-compatibility-decoder", + "version": "1.0.0", + "description": "Reference event decoder demonstrating backward-compatible schema evolution", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "jest", + "test:watch": "jest --watch", + "lint": "eslint src --ext .ts" + }, + "keywords": [ + "soroban", + "stellar", + "event-decoder", + "backward-compatibility" + ], + "author": "NotifyChain Contributors", + "license": "MIT", + "dependencies": { + "@stellar/stellar-sdk": "^12.0.0" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.0.0", + "jest": "^29.5.0", + "ts-jest": "^29.1.0", + "typescript": "^5.0.0" + } +} diff --git a/examples/event-compatibility/listener-decoder/src/event-decoder.ts b/examples/event-compatibility/listener-decoder/src/event-decoder.ts new file mode 100644 index 00000000..79d1709d --- /dev/null +++ b/examples/event-compatibility/listener-decoder/src/event-decoder.ts @@ -0,0 +1,241 @@ +/** + * Event Decoder for Backward-Compatible Schema Evolution + * + * Demonstrates how to decode events that may arrive in multiple schema versions + * within the same event stream. This pattern enables zero-downtime upgrades + * where the contract emits v2 events while old listeners still expect v1. + */ + +export interface NotificationV1 { + version: 1; + creator: string; + notificationId: string; + message: string; + // Fields below have default values when missing in v1 events + priority?: string; + category?: string; + retryCount?: number; +} + +export interface NotificationV2 { + version: 2; + creator: string; + notificationId: string; + message: string; + priority: string; + category: string; + retryCount: number; +} + +export type Notification = NotificationV1 | NotificationV2; + +/** + * Raw event structure from Stellar RPC getEvents endpoint. + * Topics are indexed fields; value contains the data payload. + */ +export interface RawEvent { + type: string; + contractId: string; + id: string; + pagingToken: string; + ledger: number; + ledgerClosedAt: string; + topics: Array<{ _value: string }>; + value: { + xdr: string; + }; +} + +/** + * Decoded event structure after XDR parsing. + */ +export interface DecodedEvent { + eventName: string; + topics: string[]; + data: { + payload_version?: number; + message?: string; + retry_count?: number; + category?: string; + [key: string]: any; + }; +} + +/** + * Decode a notification event with automatic version detection. + * + * @param event - Raw event from Stellar RPC + * @returns Notification object with version-specific fields populated + * + * @throws Error if the event structure is invalid or missing required fields + * + * @example + * // v1 event (3 topics, no priority) + * const notif = decodeNotificationCreated(v1Event); + * console.log(notif.version); // 1 + * console.log(notif.priority); // undefined (or default if applied) + * + * @example + * // v2 event (4 topics, includes priority) + * const notif = decodeNotificationCreated(v2Event); + * console.log(notif.version); // 2 + * console.log(notif.priority); // "high" + */ +export function decodeNotificationCreated(event: DecodedEvent): Notification { + const { topics, data } = event; + + // Validate minimum required fields (common to both versions) + if (topics.length < 3) { + throw new Error( + `Invalid event structure: expected at least 3 topics, got ${topics.length}` + ); + } + + if (!data.message) { + throw new Error('Missing required field: message'); + } + + // Extract common fields + // topics[0] is the event name (automatically added by Soroban) + const creator = topics[1]; + const notificationId = topics[2]; + const message = data.message; + + // Detect version from payload or topic count + const payloadVersion = data.payload_version ?? 1; + const hasV2Topics = topics.length >= 4; + + // V2 path: if payload declares v2 OR we have 4+ topics + if (payloadVersion === 2 || hasV2Topics) { + const priority = topics[3] ?? 'medium'; // Default if somehow missing + const category = data.category ?? 'general'; + const retryCount = data.retry_count ?? 0; + + return { + version: 2, + creator, + notificationId, + message, + priority, + category, + retryCount, + } as NotificationV2; + } + + // V1 path: minimal fields only + return { + version: 1, + creator, + notificationId, + message, + } as NotificationV1; +} + +/** + * Type guard to check if a notification is v2. + */ +export function isV2Notification(notif: Notification): notif is NotificationV2 { + return notif.version === 2; +} + +/** + * Normalize a notification to v2 format by filling in defaults for missing fields. + * Useful when downstream code expects a uniform interface. + */ +export function normalizeToV2(notif: Notification): NotificationV2 { + if (isV2Notification(notif)) { + return notif; + } + + // Upgrade v1 to v2 with sensible defaults + return { + version: 2, + creator: notif.creator, + notificationId: notif.notificationId, + message: notif.message, + priority: 'medium', // Default priority for legacy events + category: 'general', // Default category + retryCount: 0, // Assume first attempt for legacy + }; +} + +/** + * Batch decoder for processing event streams with mixed versions. + * + * @param events - Array of raw events from getEvents + * @returns Array of decoded notifications with version info + * + * @example + * const notifications = decodeBatch(rawEvents); + * const v2Count = notifications.filter(n => n.version === 2).length; + * console.log(`Processed ${v2Count} v2 events out of ${notifications.length} total`); + */ +export function decodeBatch(events: DecodedEvent[]): Notification[] { + const results: Notification[] = []; + const errors: Array<{ index: number; error: string }> = []; + + for (let i = 0; i < events.length; i++) { + try { + const decoded = decodeNotificationCreated(events[i]); + results.push(decoded); + } catch (error) { + errors.push({ + index: i, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (errors.length > 0) { + console.warn(`Failed to decode ${errors.length} events:`, errors); + } + + return results; +} + +/** + * Validation helper: check if an event matches the expected schema version. + * + * @param event - Decoded event to validate + * @param expectedVersion - Version number to check against + * @returns true if the event matches the expected version + */ +export function validateEventVersion( + event: DecodedEvent, + expectedVersion: number +): boolean { + const version = event.data.payload_version ?? 1; + return version === expectedVersion; +} + +/** + * Extract event statistics from a batch for monitoring/debugging. + */ +export function getEventStats(events: DecodedEvent[]): { + total: number; + v1Count: number; + v2Count: number; + unknownCount: number; +} { + let v1Count = 0; + let v2Count = 0; + let unknownCount = 0; + + for (const event of events) { + const version = event.data.payload_version ?? 1; + if (version === 1) { + v1Count++; + } else if (version === 2) { + v2Count++; + } else { + unknownCount++; + } + } + + return { + total: events.length, + v1Count, + v2Count, + unknownCount, + }; +} diff --git a/examples/event-compatibility/listener-decoder/tsconfig.json b/examples/event-compatibility/listener-decoder/tsconfig.json new file mode 100644 index 00000000..2f8cd7c2 --- /dev/null +++ b/examples/event-compatibility/listener-decoder/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/examples/event-compatibility/reference-contract/Cargo.toml b/examples/event-compatibility/reference-contract/Cargo.toml new file mode 100644 index 00000000..75cd2c7c --- /dev/null +++ b/examples/event-compatibility/reference-contract/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "event-compatibility-reference" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21.7.0" + +[dev-dependencies] +soroban-sdk = { version = "21.7.0", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true diff --git a/examples/event-compatibility/reference-contract/src/lib.rs b/examples/event-compatibility/reference-contract/src/lib.rs new file mode 100644 index 00000000..d0d02543 --- /dev/null +++ b/examples/event-compatibility/reference-contract/src/lib.rs @@ -0,0 +1,278 @@ +#![no_std] + +//! Event Compatibility Reference Contract +//! +//! Demonstrates backward-compatible event schema evolution in Soroban. +//! This contract emits two versions of the same notification event to show +//! how new fields can be added without breaking existing consumers. + +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, Symbol}; + +/// Priority level for notifications. +/// Added in v2 as a trailing topic for backward compatibility. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Priority { + Low = 0, + Medium = 1, + High = 2, + Critical = 3, +} + +/// Version 1 event data payload. +/// Original schema with just a message field. +#[contracttype] +#[derive(Clone, Debug)] +pub struct NotificationDataV1 { + pub payload_version: u32, + pub message: String, +} + +/// Version 2 event data payload. +/// Extended schema with retry tracking. +#[contracttype] +#[derive(Clone, Debug)] +pub struct NotificationDataV2 { + pub payload_version: u32, + pub message: String, + pub retry_count: u32, + pub category: String, +} + +const EVENT_NAME: Symbol = symbol_short!("NotifCrtd"); + +#[contract] +pub struct EventCompatibilityContract; + +#[contractimpl] +impl EventCompatibilityContract { + /// Create a notification using the v1 event schema. + /// + /// Topics: [event_name, creator, notification_id] + /// Data: { payload_version: 1, message } + /// + /// Existing consumers expect exactly 3 topics. This version preserves that contract. + pub fn create_notification_v1( + env: Env, + creator: Address, + notification_id: BytesN<32>, + message: String, + ) { + creator.require_auth(); + + let data = NotificationDataV1 { + payload_version: 1, + message, + }; + + // ✅ Compatible: 3 topics (event name is added automatically as topic[0]) + env.events() + .publish((EVENT_NAME, creator, notification_id), data); + } + + /// Create a notification using the v2 event schema. + /// + /// Topics: [event_name, creator, notification_id, priority] + /// Data: { payload_version: 2, message, retry_count, category } + /// + /// ✅ Backward compatible because: + /// - Topics 0-2 remain in the same positions as v1 + /// - Priority is appended as trailing topic[3] + /// - New data fields are additive (old consumers ignore unknown keys) + /// - payload_version field lets decoders branch on schema + pub fn create_notification_v2( + env: Env, + creator: Address, + notification_id: BytesN<32>, + message: String, + priority: Priority, + category: String, + ) { + creator.require_auth(); + + let data = NotificationDataV2 { + payload_version: 2, + message, + retry_count: 0, + category, + }; + + // ✅ Compatible: 4 topics, but first 3 match v1 positions + // Old consumers that only read topics[0..3] continue working + env.events() + .publish((EVENT_NAME, creator, notification_id, priority), data); + } + + /// Demonstrate both versions being emitted in the same transaction. + /// + /// This simulates a migration period where: + /// - The contract has been upgraded to support v2 + /// - Some transactions still use the v1 path (legacy API) + /// - New transactions use the v2 path + /// + /// Listeners must handle both in the same event stream. + pub fn create_notification_mixed( + env: Env, + creator: Address, + notification_id_v1: BytesN<32>, + notification_id_v2: BytesN<32>, + message: String, + ) { + creator.require_auth(); + + // Emit v1 event + let data_v1 = NotificationDataV1 { + payload_version: 1, + message: message.clone(), + }; + env.events() + .publish((EVENT_NAME, creator.clone(), notification_id_v1), data_v1); + + // Emit v2 event with enhanced metadata + let data_v2 = NotificationDataV2 { + payload_version: 2, + message, + retry_count: 0, + category: String::from_str(&env, "system"), + }; + env.events().publish( + (EVENT_NAME, creator, notification_id_v2, Priority::Medium), + data_v2, + ); + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{symbol_short, testutils::Events, vec, Env, IntoVal}; + + #[test] + fn test_v1_event_structure() { + let env = Env::default(); + let contract_id = env.register_contract(None, EventCompatibilityContract); + let client = EventCompatibilityContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let notification_id = BytesN::from_array(&env, &[1u8; 32]); + let message = String::from_str(&env, "Test notification v1"); + + client + .mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &creator, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "create_notification_v1", + args: vec![ + &env, + creator.clone().into_val(&env), + notification_id.clone().into_val(&env), + message.clone().into_val(&env), + ], + sub_invokes: &[], + }, + }]) + .create_notification_v1(&creator, ¬ification_id, &message); + + let events = env.events().all(); + assert_eq!(events.len(), 1); + + let (_contract, event) = events.get(0).unwrap(); + let topics = event.topics(); + + // Verify v1 has exactly 3 topics (event name, creator, id) + assert_eq!(topics.len(), 3); + assert_eq!(topics.get(0).unwrap(), symbol_short!("NotifCrtd").into_val(&env)); + assert_eq!(topics.get(1).unwrap(), creator.into_val(&env)); + assert_eq!(topics.get(2).unwrap(), notification_id.into_val(&env)); + } + + #[test] + fn test_v2_event_structure() { + let env = Env::default(); + let contract_id = env.register_contract(None, EventCompatibilityContract); + let client = EventCompatibilityContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let notification_id = BytesN::from_array(&env, &[2u8; 32]); + let message = String::from_str(&env, "Test notification v2"); + let category = String::from_str(&env, "alert"); + + client + .mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &creator, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "create_notification_v2", + args: vec![ + &env, + creator.clone().into_val(&env), + notification_id.clone().into_val(&env), + message.clone().into_val(&env), + Priority::High.into_val(&env), + category.into_val(&env), + ], + sub_invokes: &[], + }, + }]) + .create_notification_v2(&creator, ¬ification_id, &message, &Priority::High, &category); + + let events = env.events().all(); + assert_eq!(events.len(), 1); + + let (_contract, event) = events.get(0).unwrap(); + let topics = event.topics(); + + // Verify v2 has 4 topics (event name, creator, id, priority) + assert_eq!(topics.len(), 4); + assert_eq!(topics.get(0).unwrap(), symbol_short!("NotifCrtd").into_val(&env)); + assert_eq!(topics.get(1).unwrap(), creator.into_val(&env)); + assert_eq!(topics.get(2).unwrap(), notification_id.into_val(&env)); + + // Topics 0-2 match v1 positions — backward compatible! + // Topic 3 is new (priority) + assert_eq!(topics.get(3).unwrap(), Priority::High.into_val(&env)); + } + + #[test] + fn test_mixed_version_stream() { + let env = Env::default(); + let contract_id = env.register_contract(None, EventCompatibilityContract); + let client = EventCompatibilityContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let id_v1 = BytesN::from_array(&env, &[1u8; 32]); + let id_v2 = BytesN::from_array(&env, &[2u8; 32]); + let message = String::from_str(&env, "Mixed version test"); + + client + .mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &creator, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "create_notification_mixed", + args: vec![ + &env, + creator.clone().into_val(&env), + id_v1.clone().into_val(&env), + id_v2.clone().into_val(&env), + message.clone().into_val(&env), + ], + sub_invokes: &[], + }, + }]) + .create_notification_mixed(&creator, &id_v1, &id_v2, &message); + + let events = env.events().all(); + assert_eq!(events.len(), 2, "Should emit both v1 and v2 events"); + + // First event is v1 (3 topics) + let (_contract1, event1) = events.get(0).unwrap(); + assert_eq!(event1.topics().len(), 3, "v1 event should have 3 topics"); + + // Second event is v2 (4 topics) + let (_contract2, event2) = events.get(1).unwrap(); + assert_eq!(event2.topics().len(), 4, "v2 event should have 4 topics"); + } +}