From 81d2169a41661acb3305fbde7f840308a444e2fd Mon Sep 17 00:00:00 2001 From: IgweHub1 Date: Wed, 30 Sep 2026 08:27:00 +0000 Subject: [PATCH] feat: add event schema compatibility validation --- .../workflows/event-schema-compatibility.yml | 45 ++ .../contracts/hello-world/src/base/events.rs | 91 ---- listener/package.json | 1 + .../src/schema/compatibility-check.test.ts | 161 +++++++ listener/src/schema/compatibility-check.ts | 424 ++++++++---------- .../src/schema/consumer-event-schema.json | 174 +++++++ 6 files changed, 574 insertions(+), 322 deletions(-) create mode 100644 .github/workflows/event-schema-compatibility.yml create mode 100644 listener/src/schema/compatibility-check.test.ts create mode 100644 listener/src/schema/consumer-event-schema.json diff --git a/.github/workflows/event-schema-compatibility.yml b/.github/workflows/event-schema-compatibility.yml new file mode 100644 index 00000000..2a94c749 --- /dev/null +++ b/.github/workflows/event-schema-compatibility.yml @@ -0,0 +1,45 @@ +name: Event Schema Compatibility + +on: + pull_request: + paths: + - '.github/workflows/event-schema-compatibility.yml' + - 'contract/contracts/hello-world/src/base/events.rs' + - 'listener/package.json' + - 'listener/src/schema/**' + push: + branches: + - main + paths: + - '.github/workflows/event-schema-compatibility.yml' + - 'contract/contracts/hello-world/src/base/events.rs' + - 'listener/package.json' + - 'listener/src/schema/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + compatibility: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install listener dependencies + run: npm install --package-lock=false + working-directory: listener + - name: Check contract event compatibility + run: npm run check:event-schema + working-directory: listener + - name: Test compatibility checker with coverage + run: >- + npm test -- --runInBand --coverage + --collectCoverageFrom=src/schema/compatibility-check.ts + src/schema/compatibility-check.test.ts + working-directory: listener + - name: Typecheck listener + run: npm run typecheck + working-directory: listener diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index ce058c6a..863b3ba9 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -452,33 +452,6 @@ pub struct OwnershipTransferInitiated { } /// Emitted when a two-step ownership transfer is completed. -/// Emitted when an off-chain batch of notifications finishes processing. -#[contractevent(data_format = "single-value")] -#[derive(Clone)] -pub struct BatchProcessingCompleted { - #[topic] - pub batch_id: BytesN<32>, - #[topic] - pub category: NotificationCategory, - #[topic] - pub priority: NotificationPriority, - pub processed_count: u32, -} - -/// Emitted when an off-chain batch of notifications finishes processing. -#[contractevent(data_format = "single-value")] -#[derive(Clone)] -pub struct BatchProcessingCompleted { - #[topic] - pub batch_id: BytesN<32>, - #[topic] - pub category: NotificationCategory, - #[topic] - pub priority: NotificationPriority, - pub processed_count: u32, -} - -/// Emitted when a scheduled notification's expiry period is extended by an authorized sender. #[contractevent(data_format = "single-value")] #[derive(Clone)] pub struct OwnershipTransferred { @@ -579,70 +552,6 @@ pub struct NotificationAccessed { pub accessed_at: u64, } -// ============================================================================ -// Reputation events -// ============================================================================ - -/// Emitted when a sender's reputation score is updated. -/// Emitted when a subscriber cancels an active notification subscription. -/// -/// Off-chain consumers can key off `(group_id, subscriber)` to track the full -/// subscription lifecycle. The `group_id` identifies the AutoShare group whose -/// subscription was cancelled; `subscriber` is the address that initiated the -/// cancellation. -#[contractevent(data_format = "single-value")] -#[derive(Clone)] -pub struct SubscriptionCancelled { - /// The group whose subscription was cancelled. - #[topic] - pub group_id: BytesN<32>, - /// The address that cancelled the subscription. - #[topic] - pub subscriber: Address, - #[topic] - pub category: NotificationCategory, - #[topic] - pub priority: NotificationPriority, - /// Ledger timestamp (seconds) when the cancellation occurred. - pub cancelled_at: u64, -} - -/// Emitted when the current owner initiates a two-step ownership transfer by -/// nominating a `pending_owner`. The transfer is not final until the pending -/// owner calls `accept_ownership`. -/// -/// This mirrors the OpenZeppelin `Ownable2Step` `OwnershipTransferStarted` event -/// and lets off-chain consumers track in-progress transfers before they settle. -#[contractevent(data_format = "single-value")] -#[derive(Clone)] -pub struct ReputationUpdated { - #[topic] - pub sender: Address, - #[topic] - pub category: NotificationCategory, - #[topic] - pub priority: NotificationPriority, - pub new_score: i64, - pub successful_count: u32, - pub failed_count: u32, -} - -/// Emitted when a sender's reputation tier changes. -#[contractevent(data_format = "single-value")] -#[derive(Clone)] -pub struct ReputationTierChanged { - #[topic] - pub sender: Address, - #[topic] - pub category: NotificationCategory, - #[topic] - pub priority: NotificationPriority, - pub old_tier: u32, - pub new_tier: u32, - pub reputation_score: i64, - pub new_owner: Address, -} - /// Emitted when an authorized user updates a channel's description or metadata. /// /// Existing subscribers / members are unaffected — only descriptive metadata changes. diff --git a/listener/package.json b/listener/package.json index 4a5508e9..4edfd9f2 100644 --- a/listener/package.json +++ b/listener/package.json @@ -10,6 +10,7 @@ "lint": "node ./node_modules/typescript/bin/tsc --noEmit", "format:check": "node ./node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" --config ../.prettierrc", "test": "node ./node_modules/jest/bin/jest.js", + "check:event-schema": "ts-node src/schema/compatibility-check.ts", "test:stress": "node ./node_modules/jest/bin/jest.js src/__tests__/stress.test.ts --runInBand --detectOpenHandles", "stress-test": "ts-node src/scripts/run-stress-tests.ts", "migrate": "ts-node src/scripts/migrate-db.ts", diff --git a/listener/src/schema/compatibility-check.test.ts b/listener/src/schema/compatibility-check.test.ts new file mode 100644 index 00000000..563bb47a --- /dev/null +++ b/listener/src/schema/compatibility-check.test.ts @@ -0,0 +1,161 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + checkEventCompatibility, + checkSchemaCompatibility, + ContractEventSchema, + extractContractEvents, + loadConsumerSchema, + OffConsumerEventSchema, +} from './compatibility-check'; + +const contractEventsPath = path.resolve( + __dirname, + '../../../contract/contracts/hello-world/src/base/events.rs' +); +const consumerSchemaPath = path.resolve(__dirname, 'consumer-event-schema.json'); + +function writeTempSchema(value: unknown): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'event-schema-')); + const filePath = path.join(directory, 'consumer.json'); + fs.writeFileSync(filePath, JSON.stringify(value)); + return filePath; +} + +function makeEvent( + overrides: Partial = {} +): ContractEventSchema { + return { + name: 'TaskCreated', + topics: [{ name: 'creator', type: 'Address' }], + dataFields: [{ name: 'id', type: 'BytesN<32>' }], + ...overrides, + }; +} + +function makeConsumer( + overrides: Partial = {} +): OffConsumerEventSchema { + return { + eventName: 'TaskCreated', + expectedTopics: [{ name: 'creator', type: 'Address' }], + expectedFields: [{ name: 'id', type: 'BytesN<32>' }], + ...overrides, + }; +} + +describe('event schema compatibility check', () => { + it('extracts typed event topics and data fields from the contract source', () => { + const events = extractContractEvents(contractEventsPath); + const created = events.get('AutoshareCreated'); + + expect(events).toHaveProperty('size', 34); + expect(created).toEqual({ + name: 'AutoshareCreated', + topics: [ + { name: 'creator', type: 'Address' }, + { name: 'category', type: 'NotificationCategory' }, + { name: 'priority', type: 'NotificationPriority' }, + ], + dataFields: [{ name: 'id', type: 'BytesN<32>' }], + }); + }); + + it('accepts the checked-in schema when it matches the contract', () => { + const result = checkSchemaCompatibility( + extractContractEvents(contractEventsPath), + loadConsumerSchema(consumerSchemaPath) + ); + + expect(result.compatible).toBe(true); + expect(result.breakingChanges).toEqual([]); + expect(result.safeAdditions).toEqual([]); + }); + + it.each([ + ['removed field', makeEvent({ dataFields: [] }), makeConsumer()], + [ + 'reordered field', + makeEvent({ dataFields: [{ name: 'new_id', type: 'BytesN<32>' }] }), + makeConsumer(), + ], + [ + 'incompatible field type', + makeEvent({ dataFields: [{ name: 'id', type: 'String' }] }), + makeConsumer(), + ], + [ + 'incompatible topic type', + makeEvent({ topics: [{ name: 'creator', type: 'String' }] }), + makeConsumer(), + ], + ])('rejects a %s', (_description, contractEvent, consumerEvent) => { + const result = checkEventCompatibility( + contractEvent as ContractEventSchema, + consumerEvent as OffConsumerEventSchema + ); + + expect(result.compatible).toBe(false); + expect(result.breakingChanges.length).toBeGreaterThan(0); + }); + + it('accepts and reports trailing topics and data fields', () => { + const result = checkEventCompatibility( + makeEvent({ + topics: [ + { name: 'creator', type: 'Address' }, + { name: 'category', type: 'NotificationCategory' }, + ], + dataFields: [ + { name: 'id', type: 'BytesN<32>' }, + { name: 'created_at', type: 'u64' }, + ], + }), + makeConsumer() + ); + + expect(result.compatible).toBe(true); + expect(result.breakingChanges).toEqual([]); + expect(result.safeAdditions).toEqual([ + "Safe addition: Trailing topic 'category' added to 'TaskCreated'", + "Safe addition: Trailing data field 'created_at' added to 'TaskCreated'", + ]); + }); + + it('accepts new contract events and rejects removed expected events', () => { + const contractEvents = new Map([ + ['TaskCreated', makeEvent()], + ['TaskUpdated', makeEvent({ name: 'TaskUpdated' })], + ]); + const compatible = checkSchemaCompatibility(contractEvents, { + events: [makeConsumer()], + }); + const missing = checkSchemaCompatibility(new Map(), { events: [makeConsumer()] }); + + expect(compatible.compatible).toBe(true); + expect(compatible.safeAdditions).toEqual([ + "Safe addition: New event 'TaskUpdated' is ignored by existing consumers", + ]); + expect(missing.compatible).toBe(false); + expect(missing.breakingChanges[0]).toContain("event 'TaskCreated'"); + }); + + it('rejects empty or malformed consumer schemas', () => { + const emptyPath = writeTempSchema({ events: [] }); + const malformedPath = writeTempSchema({ events: [{ eventName: 'TaskCreated' }] }); + const duplicatePath = writeTempSchema({ events: [makeConsumer(), makeConsumer()] }); + + expect(() => loadConsumerSchema(emptyPath)).toThrow('valid event names'); + expect(() => loadConsumerSchema(malformedPath)).toThrow('valid event names'); + expect(() => loadConsumerSchema(duplicatePath)).toThrow('duplicate event names'); + + fs.rmSync(path.dirname(emptyPath), { recursive: true, force: true }); + fs.rmSync(path.dirname(malformedPath), { recursive: true, force: true }); + fs.rmSync(path.dirname(duplicatePath), { recursive: true, force: true }); + }); + + it('rejects an empty contract schema', () => { + expect(checkSchemaCompatibility(new Map(), { events: [makeConsumer()] }).compatible).toBe(false); + }); +}); diff --git a/listener/src/schema/compatibility-check.ts b/listener/src/schema/compatibility-check.ts index 1822c8cb..3f9df1eb 100644 --- a/listener/src/schema/compatibility-check.ts +++ b/listener/src/schema/compatibility-check.ts @@ -1,290 +1,252 @@ /** - * Event Schema Compatibility Check (#700) - * - * Automated checks to detect incompatible changes between the contract event - * schema and off-chain event consumers. Uses the contract event definitions - * from the Soroban contract as the authoritative source and validates that - * off-chain parsers/consumers can still process events after schema changes. - * - * Breaking changes that fail validation: - * - Removed required fields - * - Incompatible field types - * - Incompatible changes to existing event structure - * - Changes that would prevent existing consumers from processing events - * - * Compatible additions that pass validation: - * - New optional fields - * - New event types (without removing existing ones) - * - New enum variants (backward compatible when appended) - * - New topics (trailing, ignored by existing consumers) + * Checks that contract event changes remain compatible with the off-chain + * consumer schema. */ import * as fs from 'fs'; import * as path from 'path'; -// ─── Contract Event Schema (authoritative source) ─────────────────────────────── - -/** - * Extracts the event field names and topics from a Soroban contract event. - * Parses the events.rs file to build the authoritative schema. - */ -function extractContractEvents(eventsFilePath: string): Map { - const source = fs.readFileSync(eventsFilePath, 'utf8'); - - const eventsMap = new Map(); - - // Match event struct definitions: `#[contractevent]#[derive(Clone)] pub struct EventName { ... }` - const eventRegex = /#[contractevent[^\]]*\]#[^\n]*\npub struct (\w+) \{([^}]*)\}/gs; - let match: RegExpExecArray | null; +export interface EventFieldSchema { + name: string; + type: string; +} - while ((match = eventRegex.exec(source)) !== null) { - const eventName = match[1]; - const fieldsSection = match[2]; +export interface ContractEventSchema { + name: string; + topics: EventFieldSchema[]; + dataFields: EventFieldSchema[]; +} - const topics: string[] = []; - const dataFields: { name: string; type: string }[] = []; +export interface OffConsumerEventSchema { + eventName: string; + expectedTopics: EventFieldSchema[]; + expectedFields: EventFieldSchema[]; +} - // Match topic fields: `#[topic] pub fieldName: Type` - const topicRegex = /#\[topic\]\s+pub\s+(\w+):\s*(\w+(?:<[^>]+>)?)/gm; - let topicMatch: RegExpExecArray | null; +export interface OffConsumerSchema { + events: OffConsumerEventSchema[]; +} - while ((topicMatch = topicRegex.exec(fieldsSection)) !== null) { - topics.push(topicMatch[1]); - } +export interface CompatibilityResult { + compatible: boolean; + breakingChanges: string[]; + safeAdditions: string[]; +} - // Match data fields (non-topic): `pub fieldName: Type` - const dataFieldRegex = /pub\s+(\w+):\s*(\w+(?:<[^>]+>)?)(?:\s*[\[\],])/gm; - let dataMatch: RegExpExecArray | null; +export function extractContractEvents(eventsFilePath: string): Map { + const source = fs.readFileSync(eventsFilePath, 'utf8'); + const eventRegex = /#\[contractevent[^\]]*\][\s\S]*?\bpub struct (\w+)\s*\{([\s\S]*?)^\}/gm; + const events = new Map(); + let match: RegExpExecArray | null; - while ((dataMatch = dataFieldRegex.exec(fieldsSection)) !== null) { - // Skip if this field was already captured as a topic - if (!topics.includes(dataMatch[1])) { - dataFields.push({ - name: dataMatch[1], - type: dataMatch[2], - }); + while ((match = eventRegex.exec(source)) !== null) { + const [, eventName, fieldsSection] = match; + const topics: EventFieldSchema[] = []; + const dataFields: EventFieldSchema[] = []; + let nextFieldIsTopic = false; + + for (const line of fieldsSection.split(/\r?\n/)) { + const fieldLine = line.trim(); + if (fieldLine === '#[topic]') { + nextFieldIsTopic = true; + continue; } - } - // Determine if events have category/priority (from the NotifyChain contract) - const hasCategory = fieldsSection.includes('category: NotificationCategory'); - const hasPriority = fieldsSection.includes('priority: NotificationPriority'); + const fieldMatch = fieldLine.match(/^pub\s+(\w+)\s*:\s*(.+)$/); + if (!fieldMatch) continue; + const field = { + name: fieldMatch[1], + type: fieldMatch[2].replace(/,\s*$/, '').trim(), + }; + (nextFieldIsTopic ? topics : dataFields).push(field); + nextFieldIsTopic = false; + } - eventsMap.set(eventName, { - name: eventName, - topics, - dataFields, - hasCategory, - hasPriority, - }); + if (events.has(eventName)) throw new Error(`Duplicate contract event '${eventName}'`); + events.set(eventName, { name: eventName, topics, dataFields }); } - return eventsMap; + return events; } -interface ContractEventSchema { - name: string; - topics: string[]; - dataFields: { name: string; type: string }[]; - hasCategory: boolean; - hasPriority: boolean; +function isEventFieldSchema(value: unknown): value is EventFieldSchema { + if (typeof value !== 'object' || value === null) return false; + const field = value as EventFieldSchema; + return typeof field.name === 'string' && !!field.name.trim() && + typeof field.type === 'string' && !!field.type.trim(); } -// ─── Off-Consumer Event Types ────────────────────────────────────────────────── - -/** - * Minimal representation of what an off-chain consumer expects. - * In a real implementation, this would be parsed from the consumer TypeScript types. - */ -interface OffConsumerSchema { - eventName: string; - expectedFields: string[]; // Field names expected by the consumer - expectedTopics?: string[]; // Expected topic names - hasCategory?: boolean; - hasPriority?: boolean; +function isConsumerEventSchema(value: unknown): value is OffConsumerEventSchema { + if (typeof value !== 'object' || value === null) return false; + const event = value as OffConsumerEventSchema; + return typeof event.eventName === 'string' && !!event.eventName.trim() && + Array.isArray(event.expectedTopics) && event.expectedTopics.every(isEventFieldSchema) && + Array.isArray(event.expectedFields) && event.expectedFields.every(isEventFieldSchema); } -// ─── Compatibility Logic ─────────────────────────────────────────────────────── +export function loadConsumerSchema(schemaFilePath: string): OffConsumerSchema { + const parsed: unknown = JSON.parse(fs.readFileSync(schemaFilePath, 'utf8')); + if (typeof parsed !== 'object' || parsed === null || !Array.isArray((parsed as OffConsumerSchema).events)) { + throw new Error('Consumer schema must be a JSON object with an events array'); + } -/** - * Checks if an off-chain consumer can still process a given contract event, - * given the current contract schema and the consumer's expected schema. - */ -function checkEventCompatibility( + const events = (parsed as OffConsumerSchema).events; + if (events.length === 0 || !events.every(isConsumerEventSchema)) { + throw new Error('Consumer schema must contain valid event names, topics, and data fields'); + } + const eventNames = events.map((event) => event.eventName); + if (new Set(eventNames).size !== eventNames.length) { + throw new Error('Consumer schema contains duplicate event names'); + } + return { events }; +} + +function compareFields( contractEvent: ContractEventSchema, - consumer: OffConsumerSchema -): { compatible: boolean; breakingChanges: string[]; safeAdditions: string[] } -{ + actualFields: EventFieldSchema[], + expectedFields: EventFieldSchema[], + fieldKind: string +): { compatible: boolean; breakingChanges: string[]; safeAdditions: string[] } { const breakingChanges: string[] = []; const safeAdditions: string[] = []; - // Check: consumer expects events that no longer exist in contract - if (!contractEvent.name) { - breakingChanges.push('Contract event schema is empty or malformed'); - return { compatible: false, breakingChanges, safeAdditions }; - } - - // Check 1: Required fields removed - // A field is "required" if the consumer expects it and it exists in the contract - // A "breaking change" occurs if the contract REMOVED a field that the consumer expects - const contractFieldNames = new Set(contractEvent.dataFields.map((f) => f.name)); - const consumerFieldNames = new Set(consumer.expectedFields); - - for (const field of consumerFieldNames) { - if (!contractFieldNames.has(field)) { - // Consumer expects this field, but it's no longer in the contract + expectedFields.forEach((expectedField, index) => { + const actualField = actualFields[index]; + if (!actualField) { + breakingChanges.push( + `Breaking: Contract event '${contractEvent.name}' is missing expected ${fieldKind} '${expectedField.name}'` + ); + } else if (actualField.name !== expectedField.name) { breakingChanges.push( - `Breaking: Consumer expects field '${field}' for event '${contractEvent.name}', ` + - `but it has been removed from the contract schema` + `Breaking: Expected ${fieldKind} '${expectedField.name}' at position ${index} in '${contractEvent.name}', found '${actualField.name}'` + ); + } else if (actualField.type.replace(/\s+/g, '') !== expectedField.type.replace(/\s+/g, '')) { + breakingChanges.push( + `Breaking: ${fieldKind} '${expectedField.name}' in '${contractEvent.name}' changed type from '${expectedField.type}' to '${actualField.type}'` ); } + }); + + if (breakingChanges.length === 0) { + safeAdditions.push( + ...actualFields.slice(expectedFields.length).map( + (field) => `Safe addition: Trailing ${fieldKind} '${field.name}' added to '${contractEvent.name}'` + ) + ); } + return { compatible: breakingChanges.length === 0, breakingChanges, safeAdditions }; +} + +export function checkEventCompatibility( + contractEvent: ContractEventSchema, + consumerEvent: OffConsumerEventSchema +): CompatibilityResult { + const topics = compareFields( + contractEvent, + contractEvent.topics, + consumerEvent.expectedTopics, + 'topic' + ); + const fields = compareFields( + contractEvent, + contractEvent.dataFields, + consumerEvent.expectedFields, + 'data field' + ); + return { + compatible: topics.compatible && fields.compatible, + breakingChanges: [...topics.breakingChanges, ...fields.breakingChanges], + safeAdditions: [...topics.safeAdditions, ...fields.safeAdditions], + }; +} - // Check 2: Incompatible field types - // This is a simplified check - in practice would need full type resolution - // For now, we check if the number/types of topics have changed in breaking ways +export function checkSchemaCompatibility( + contractEvents: Map, + consumerSchema: OffConsumerSchema +): CompatibilityResult { + const breakingChanges: string[] = []; + const safeAdditions: string[] = []; + const consumerNames = new Set(consumerSchema.events.map((event) => event.eventName)); - // Check 3: Category/priority removal - if (contractEvent.hasPriority && !consumer.hasPriority) { - // Contract still has priority but consumer doesn't expect it - // This is usually fine (consumer can ignore trailing topic) - } else if (!contractEvent.hasPriority && consumer.hasPriority) { - // Contract removed priority, consumer still expects it - breakingChanges.push( - `Breaking: Event '${contractEvent.name}' no longer emits priority topic, ` + - `but consumer expects it. Add priority field or update consumer.` - ); + if (consumerSchema.events.length === 0) { + breakingChanges.push('Consumer event schema is empty or malformed'); } - // Check 4: Topic structure changes - // Topics are appended as trailing topics - existing consumers ignore them - // Breaking change only if the core topic (event name) changes position - if (contractEvent.topics.length < consumer.expectedTopics?.length) { - // Contract has fewer topics than consumer expects - // This could be breaking if the consumer relies on specific topic positions - const missingTopics = consumer.expectedTopics.filter( - (t) => !contractEvent.topics.includes(t) - ); - if (missingTopics.length > 0) { + for (const consumerEvent of consumerSchema.events) { + const contractEvent = contractEvents.get(consumerEvent.eventName); + if (!contractEvent) { breakingChanges.push( - `Breaking: Contract event '${contractEvent.name}' is missing expected topics: ${missingTopics.join(', ')}` + `Breaking: Consumer expects event '${consumerEvent.eventName}', but it is missing from the contract schema` ); + continue; } + const result = checkEventCompatibility(contractEvent, consumerEvent); + breakingChanges.push(...result.breakingChanges); + safeAdditions.push(...result.safeAdditions); } - // Check 5: New data fields are safe (backward compatible) - // Any new data fields in the contract that weren't expected by the consumer - // are simply ignored - this is the Soroban trailing-topic pattern - const newDataFields = dataFields.filter( - (f) => !consumer.expectedFields.includes(f.name) - ); - safeAdditions.push( - ...newDataFields.map( - (f) => `Safe addition: New data field '${f.name}' in '${contractEvent.name}' (ignored by existing consumers)` - ) - ); - - // Determine compatibility - const compatible = breakingChanges.length === 0; + for (const eventName of contractEvents.keys()) { + if (!consumerNames.has(eventName)) { + safeAdditions.push(`Safe addition: New event '${eventName}' is ignored by existing consumers`); + } + } - return { compatible, breakingChanges, safeAdditions }; + return { compatible: breakingChanges.length === 0, breakingChanges, safeAdditions }; } -// ─── CLI Entry Point ────────────────────────────────────────────────────────── +function optionValue(args: string[], option: string): string | undefined { + const index = args.indexOf(option); + if (index === -1) return undefined; + const value = args[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`Option ${option} requires a value`); + return value; +} -function main(): void { +export function main(): void { const args = process.argv.slice(2); - if (args.includes('--help') || args.includes('-h')) { - console.log(` -Event Schema Compatibility Check (Issue #700) - -Usage: - npx ts-node --project ../tsconfig.json listener/src/schema/compatibility-check.ts [options] - -Options: - --contract Path to contract events.rs file (default: contract/contracts/hello-world/src/base/events.rs) - --consumer Path to consumer schema JSON file - --output Output report file (default: stdout) - --format Output format: table|json|summary (default: table) - --breakdown Show detailed breaking change breakdown - -The check compares the contract event schema (authoritative source) -against off-chain consumer expectations and reports: - - Compatible changes (safe to proceed) - - Breaking changes (CI will fail) - - Safe additions (new fields ignored by existing consumers) -`); - process.exit(0); + console.log('Usage: npm run check:event-schema -- [--contract path] [--consumer path] [--format table|json|summary]'); + return; } - const contractPath = - args[args.indexOf('--contract') + 1] || - 'contract/contracts/hello-world/src/base/events.rs'; - - // For demonstration, create a mock consumer schema - // In practice, this would be parsed from the actual off-chain consumer TypeScript types - const mockConsumer: OffConsumerSchema = { - eventName: 'AutoshareCreated', - expectedFields: ['creator', 'id'], - expectedTopics: ['autoshare_created', 'creator', 'category', 'priority'], - hasCategory: true, - hasPriority: true, - }; - try { - const contractEvents = extractContractEvents(contractPath); - - console.log('=== Event Schema Compatibility Check ===\n'); - console.log('Authoritative contract event schema extracted from:', contractPath); - console.log(''); - - let totalEvents = 0; - let compatibleEvents = 0; - let breakingEvents = 0; - - for (const [eventName, contractEvent] of contractEvents) { - totalEvents++; - - const { compatible, breakingChanges, safeAdditions } = checkEventCompatibility( - contractEvent, - mockConsumer - ); - - if (compatible) { - compatibleEvents++; - console.log(`✅ ${eventName}: COMPATIBLE`); - if (safeAdditions.length > 0) { - safeAdditions.forEach((a) => console.log(` + ${a}`)); - } - } else { - breakingEvents++; - console.log(`❌ ${eventName}: BREAKING CHANGES`); - breakingChanges.forEach((bc) => console.log(` ! ${bc}`)); - if (safeAdditions.length > 0) { - safeAdditions.forEach((a) => console.log(` + ${a}`)); - } - } - console.log(''); + const contractPath = path.resolve( + process.cwd(), + optionValue(args, '--contract') ?? + path.resolve(__dirname, '../../../contract/contracts/hello-world/src/base/events.rs') + ); + const consumerPath = path.resolve( + process.cwd(), + optionValue(args, '--consumer') ?? path.resolve(__dirname, 'consumer-event-schema.json') + ); + const format = optionValue(args, '--format') ?? 'table'; + if (!['table', 'json', 'summary'].includes(format)) { + throw new Error(`Unsupported output format '${format}'`); } - console.log(`=== Summary ===`); - console.log(`Total events checked: ${totalEvents}`); - console.log(`Compatible: ${compatibleEvents}`); - console.log(`Breaking changes: ${breakingEvents}`); - console.log(''); - - if (breakingEvents > 0) { - console.log('⚠️ Compatibility check FAILED - breaking changes detected'); - process.exit(1); + const result = checkSchemaCompatibility( + extractContractEvents(contractPath), + loadConsumerSchema(consumerPath) + ); + const report = format === 'json' + ? JSON.stringify(result, null, 2) + : [ + `Event Schema Compatibility Check: ${result.compatible ? 'PASS' : 'FAIL'}`, + ...(format === 'table' ? result.breakingChanges.map((change) => `! ${change}`) : []), + ...(format === 'table' ? result.safeAdditions.map((addition) => `+ ${addition}`) : []), + `Breaking changes: ${result.breakingChanges.length}`, + ].join('\n'); + const outputPath = optionValue(args, '--output'); + if (outputPath) { + fs.writeFileSync(path.resolve(process.cwd(), outputPath), `${report}\n`); } else { - console.log('✅ Compatibility check PASSED - all events are compatible'); - process.exit(0); + console.log(report); } + process.exitCode = result.compatible ? 0 : 1; } catch (error) { console.error('Error running compatibility check:', error); - process.exit(1); + process.exitCode = 1; } } -main(); \ No newline at end of file +if (require.main === module) main(); diff --git a/listener/src/schema/consumer-event-schema.json b/listener/src/schema/consumer-event-schema.json new file mode 100644 index 00000000..b444e83b --- /dev/null +++ b/listener/src/schema/consumer-event-schema.json @@ -0,0 +1,174 @@ +{ + "events": [ + { + "eventName": "AutoshareCreated", + "expectedTopics": [{ "name": "creator", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "id", "type": "BytesN<32>" }] + }, + { + "eventName": "AutoshareUpdated", + "expectedTopics": [{ "name": "updater", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "id", "type": "BytesN<32>" }] + }, + { + "eventName": "GroupDeactivated", + "expectedTopics": [{ "name": "creator", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "id", "type": "BytesN<32>" }] + }, + { + "eventName": "GroupActivated", + "expectedTopics": [{ "name": "creator", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "id", "type": "BytesN<32>" }] + }, + { + "eventName": "CategoryRegistered", + "expectedTopics": [{ "name": "admin", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [] + }, + { + "eventName": "ChannelPreferenceUpdated", + "expectedTopics": [{ "name": "recipient", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "channel", "type": "u32" }, { "name": "enabled", "type": "bool" }, { "name": "updated_at", "type": "u64" }] + }, + { + "eventName": "ContractPaused", + "expectedTopics": [{ "name": "admin", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [] + }, + { + "eventName": "ContractUnpaused", + "expectedTopics": [{ "name": "admin", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [] + }, + { + "eventName": "AdminTransferred", + "expectedTopics": [{ "name": "old_admin", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "new_admin", "type": "Address" }] + }, + { + "eventName": "AuthorizationFailure", + "expectedTopics": [{ "name": "caller", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "action", "type": "String" }] + }, + { + "eventName": "Withdrawal", + "expectedTopics": [{ "name": "token", "type": "Address" }, { "name": "recipient", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "amount", "type": "i128" }] + }, + { + "eventName": "NotificationScheduled", + "expectedTopics": [{ "name": "creator", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "notification_id", "type": "BytesN<32>" }, { "name": "payload_version", "type": "u32" }] + }, + { + "eventName": "NotificationExpired", + "expectedTopics": [{ "name": "notification_id", "type": "BytesN<32>" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "expires_at", "type": "u64" }] + }, + { + "eventName": "ScheduledNotificationCancelled", + "expectedTopics": [{ "name": "caller", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "notification_id", "type": "BytesN<32>" }] + }, + { + "eventName": "NotificationDelivered", + "expectedTopics": [{ "name": "notification_id", "type": "BytesN<32>" }, { "name": "delivered_by", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "delivered_at", "type": "u64" }] + }, + { + "eventName": "NotificationRecalled", + "expectedTopics": [{ "name": "notification_id", "type": "BytesN<32>" }, { "name": "recalled_by", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "recalled_at", "type": "u64" }] + }, + { + "eventName": "NotificationRevoked", + "expectedTopics": [{ "name": "notification_id", "type": "BytesN<32>" }, { "name": "revoked_by", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [] + }, + { + "eventName": "NotificationExtended", + "expectedTopics": [{ "name": "notification_id", "type": "BytesN<32>" }, { "name": "caller", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "new_expires_at", "type": "u64" }] + }, + { + "eventName": "NotificationAcknowledged", + "expectedTopics": [{ "name": "notification_id", "type": "BytesN<32>" }, { "name": "acknowledger", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "timestamp", "type": "u64" }] + }, + { + "eventName": "SubscriptionCancelled", + "expectedTopics": [{ "name": "group_id", "type": "BytesN<32>" }, { "name": "subscriber", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "cancelled_at", "type": "u64" }] + }, + { + "eventName": "BatchNotificationsCreated", + "expectedTopics": [{ "name": "creator", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "count", "type": "u32" }, { "name": "ids", "type": "Vec>" }] + }, + { + "eventName": "BatchProcessingCompleted", + "expectedTopics": [{ "name": "batch_id", "type": "BytesN<32>" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "processed_count", "type": "u32" }] + }, + { + "eventName": "AuditRecordAppended", + "expectedTopics": [{ "name": "notification_id", "type": "BytesN<32>" }, { "name": "action", "type": "AuditAction" }, { "name": "category", "type": "NotificationCategory" }], + "expectedFields": [{ "name": "seq", "type": "u64" }, { "name": "actor", "type": "Address" }] + }, + { + "eventName": "OwnershipTransferInitiated", + "expectedTopics": [{ "name": "previous_owner", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "pending_owner", "type": "Address" }] + }, + { + "eventName": "OwnershipTransferred", + "expectedTopics": [{ "name": "previous_owner", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "new_owner", "type": "Address" }] + }, + { + "eventName": "ReputationUpdated", + "expectedTopics": [{ "name": "sender", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "new_score", "type": "i64" }, { "name": "successful_count", "type": "u32" }, { "name": "failed_count", "type": "u32" }] + }, + { + "eventName": "NotificationLimitsConfigured", + "expectedTopics": [{ "name": "admin", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "max_payload_size", "type": "u32" }, { "name": "max_expiration_seconds", "type": "u64" }, { "name": "min_expiration_seconds", "type": "u64" }, { "name": "max_batch_size", "type": "u32" }] + }, + { + "eventName": "ReputationTierChanged", + "expectedTopics": [{ "name": "sender", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "old_tier", "type": "u32" }, { "name": "new_tier", "type": "u32" }, { "name": "reputation_score", "type": "i64" }] + }, + { + "eventName": "SchemaVersionSet", + "expectedTopics": [{ "name": "admin", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "schema_version", "type": "u32" }, { "name": "previous_version", "type": "u32" }] + }, + { + "eventName": "NotificationAccessed", + "expectedTopics": [{ "name": "notification_id", "type": "BytesN<32>" }, { "name": "accessor", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }], + "expectedFields": [{ "name": "accessed_at", "type": "u64" }] + }, + { + "eventName": "ChannelMetadataUpdated", + "expectedTopics": [{ "name": "channel_id", "type": "BytesN<32>" }, { "name": "updater", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "updated_at", "type": "u64" }] + }, + { + "eventName": "NotificationArchived", + "expectedTopics": [{ "name": "notification_id", "type": "BytesN<32>" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "archived_at", "type": "u64" }, { "name": "archive_reason", "type": "String" }] + }, + { + "eventName": "TemplateRegistered", + "expectedTopics": [{ "name": "owner", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "template_id", "type": "BytesN<32>" }] + }, + { + "eventName": "TemplateUpdated", + "expectedTopics": [{ "name": "owner", "type": "Address" }, { "name": "category", "type": "NotificationCategory" }, { "name": "priority", "type": "NotificationPriority" }], + "expectedFields": [{ "name": "template_id", "type": "BytesN<32>" }] + } + ] +}