diff --git a/backend/shared/events/__tests__/schemaVersioning.test.ts b/backend/shared/events/__tests__/schemaVersioning.test.ts new file mode 100644 index 00000000..bdbac106 --- /dev/null +++ b/backend/shared/events/__tests__/schemaVersioning.test.ts @@ -0,0 +1,230 @@ +import { + SchemaVersionRegistry, + diffSchemas, + validateAgainstVersion, + type SchemaField, + type VersionedSchema, +} from '../schemaVersioning'; + +function field(type: SchemaField['type'], required: boolean, opts: Partial = {}): SchemaField { + return { type, required, description: opts.description ?? 'test', ...opts }; +} + +function schema(type: string, version: number, fields: Record): VersionedSchema { + return { type, version, fields }; +} + +describe('schemaVersioning', () => { + describe('diffSchemas', () => { + it('reports full compatibility when only optional fields are added', () => { + const v1 = schema('subscription.created', 1, { id: field('string', true) }); + const v2 = schema('subscription.created', 2, { + id: field('string', true), + note: field('string', false), + }); + const diff = diffSchemas('subscription.created', v1, v2); + expect(diff.compatibility).toBe('full'); + expect(diff.breaking).toBe(false); + expect(diff.changes).toHaveLength(1); + expect(diff.changes[0].change).toBe('added'); + }); + + it('flags added-required-without-default as backward-incompatible', () => { + const v1 = schema('evt', 1, { id: field('string', true) }); + const v2 = schema('evt', 2, { id: field('string', true), owner: field('string', true) }); + const diff = diffSchemas('evt', v1, v2); + expect(diff.backwardCompatible).toBe(false); + expect(diff.forwardCompatible).toBe(true); + expect(diff.compatibility).toBe('forward'); + expect(diff.incompatibilities.length).toBeGreaterThan(0); + }); + + it('treats added-required-with-default as backward compatible', () => { + const v1 = schema('evt', 1, { id: field('string', true) }); + const v2 = schema('evt', 2, { + id: field('string', true), + owner: field('string', true, { default: 'unknown' }), + }); + const diff = diffSchemas('evt', v1, v2); + expect(diff.backwardCompatible).toBe(true); + expect(diff.compatibility).toBe('full'); + }); + + it('flags removed-required as forward-incompatible', () => { + const v1 = schema('evt', 1, { id: field('string', true), reason: field('string', true) }); + const v2 = schema('evt', 2, { id: field('string', true) }); + const diff = diffSchemas('evt', v1, v2); + expect(diff.forwardCompatible).toBe(false); + expect(diff.backwardCompatible).toBe(true); + expect(diff.compatibility).toBe('backward'); + }); + + it('flags type change as breaking in both directions', () => { + const v1 = schema('evt', 1, { amount: field('number', true) }); + const v2 = schema('evt', 2, { amount: field('string', true) }); + const diff = diffSchemas('evt', v1, v2); + expect(diff.compatibility).toBe('none'); + expect(diff.breaking).toBe(true); + }); + + it('flags optional→required without default as backward-incompatible', () => { + const v1 = schema('evt', 1, { id: field('string', true), note: field('string', false) }); + const v2 = schema('evt', 2, { id: field('string', true), note: field('string', true) }); + const diff = diffSchemas('evt', v1, v2); + expect(diff.backwardCompatible).toBe(false); + }); + + it('treats required→optional as forward-incompatible', () => { + const v1 = schema('evt', 1, { id: field('string', true), reason: field('string', true) }); + const v2 = schema('evt', 2, { id: field('string', true), reason: field('string', false) }); + const diff = diffSchemas('evt', v1, v2); + expect(diff.forwardCompatible).toBe(false); + }); + + it('detects default and description changes', () => { + const v1 = schema('evt', 1, { id: field('string', true, { default: 'a', description: 'one' }) }); + const v2 = schema('evt', 2, { id: field('string', true, { default: 'b', description: 'two' }) }); + const diff = diffSchemas('evt', v1, v2); + const changeTypes = diff.changes.map(c => c.change); + expect(changeTypes).toContain('default-changed'); + expect(changeTypes).toContain('description-changed'); + expect(diff.compatibility).toBe('full'); + }); + + it('reports no changes for identical schemas', () => { + const v1 = schema('evt', 1, { id: field('string', true) }); + const v2 = schema('evt', 2, { id: field('string', true) }); + const diff = diffSchemas('evt', v1, v2); + expect(diff.changes).toHaveLength(0); + expect(diff.compatibility).toBe('full'); + }); + }); + + describe('validateAgainstVersion', () => { + it('reports missing required fields', () => { + const s = schema('evt', 1, { id: field('string', true), amount: field('number', true) }); + const errors = validateAgainstVersion(s, { id: 'x' }); + expect(errors).toContain('Missing required field: amount'); + }); + + it('reports type mismatches', () => { + const s = schema('evt', 1, { amount: field('number', true) }); + const errors = validateAgainstVersion(s, { amount: 'not-a-number' }); + expect(errors.some(e => e.includes('expected type "number"'))).toBe(true); + }); + + it('passes for a valid payload', () => { + const s = schema('evt', 1, { id: field('string', true), amount: field('number', true), note: field('string', false) }); + expect(validateAgainstVersion(s, { id: 'x', amount: 5 })).toEqual([]); + }); + + it('validates array and object types', () => { + const s = schema('evt', 1, { tags: field('array', true), meta: field('object', true) }); + expect(validateAgainstVersion(s, { tags: [1], meta: { a: 1 } })).toEqual([]); + expect(validateAgainstVersion(s, { tags: 'x', meta: [1] }).length).toBeGreaterThan(0); + }); + }); + + describe('SchemaVersionRegistry', () => { + function registry(): SchemaVersionRegistry { + const r = new SchemaVersionRegistry(); + r.registerVersion('sub.created', 1, { id: field('string', true), planId: field('string', true) }); + r.registerVersion('sub.created', 2, { + id: field('string', true), + planId: field('string', true), + currency: field('string', true, { default: 'USDC' }), + }); + r.registerVersion('sub.created', 3, { + id: field('string', true), + planId: field('string', true), + currency: field('string', true, { default: 'USDC' }), + metadata: field('object', false), + }); + return r; + } + + it('stores and retrieves versions', () => { + const r = registry(); + expect(r.getVersion('sub.created', 1)?.version).toBe(1); + expect(r.getLatestVersion('sub.created')?.version).toBe(3); + expect(r.getVersions('sub.created')).toEqual([1, 2, 3]); + }); + + it('rejects re-registering an existing version', () => { + const r = new SchemaVersionRegistry(); + r.registerVersion('evt', 1, { id: field('string', true) }); + expect(() => r.registerVersion('evt', 1, { id: field('string', true) })).toThrow(); + }); + + it('diffs registered versions', () => { + const r = registry(); + const d = r.diff('sub.created', 1, 3); + expect(d.fromVersion).toBe(1); + expect(d.toVersion).toBe(3); + expect(d.changes.some(c => c.field === 'metadata')).toBe(true); + }); + + it('assertCompatible throws on a breaking requirement', () => { + const r = new SchemaVersionRegistry(); + r.registerVersion('evt', 1, { id: field('string', true) }); + r.registerVersion('evt', 2, { id: field('string', true), owner: field('string', true) }); + expect(() => r.assertCompatible('evt', 1, 2, 'backward')).toThrow(); + expect(() => r.assertCompatible('evt', 1, 2, 'forward')).not.toThrow(); + }); + + it('auto-migrates by filling defaults for newly-required fields', () => { + const r = registry(); + const migrated = r.migrate('sub.created', { id: 'e1', planId: 'p1' }, 1, 3); + expect(migrated.id).toBe('e1'); + expect(migrated.planId).toBe('p1'); + expect(migrated.currency).toBe('USDC'); // default filled + expect(migrated.metadata).toBeUndefined(); // optional, absent + }); + + it('applies an explicit migrator when registered', () => { + const r = registry(); + r.registerMigrator('sub.created', 1, 2, (p) => ({ + ...p, + currency: (p.currency as string) ?? 'EUR', + })); + const migrated = r.migrate('sub.created', { id: 'e1', planId: 'p1' }, 1, 2); + expect(migrated.currency).toBe('EUR'); + }); + + it('chains migrators across multiple versions', () => { + const r = registry(); + r.registerMigrator('sub.created', 1, 2, (p) => ({ ...p, currency: 'EUR' })); + r.registerMigrator('sub.created', 2, 3, (p) => ({ ...p, metadata: { migrated: true } })); + const migrated = r.migrate('sub.created', { id: 'e1', planId: 'p1' }, 1, 3); + expect(migrated.currency).toBe('EUR'); + expect(migrated.metadata).toEqual({ migrated: true }); + }); + + it('rejects downgrade migration', () => { + const r = registry(); + expect(() => r.migrate('sub.created', { id: 'x', planId: 'p' }, 3, 1)).toThrow(); + }); + + it('rejects invalid migrator direction', () => { + const r = registry(); + expect(() => r.registerMigrator('sub.created', 2, 1, (p) => p)).toThrow(); + }); + + it('returns the payload unchanged for same-version migrate', () => { + const r = registry(); + const out = r.migrate('sub.created', { id: 'x', planId: 'p' }, 2, 2); + expect(out).toEqual({ id: 'x', planId: 'p' }); + }); + + it('validateAgainstVersion catches migrated-payload issues', () => { + const r = registry(); + // v2 requires currency; an explicit migrator that forgets to set it + // leaves the field absent and validation should flag it. + r.registerMigrator('sub.created', 1, 2, (p) => ({ ...p })); // forgets currency + const migrated = r.migrate('sub.created', { id: 'x', planId: 'p' }, 1, 2); + const v2 = r.getVersion('sub.created', 2)!; + const errors = validateAgainstVersion(v2, migrated); + expect(errors.some(e => e.includes('currency'))).toBe(true); + }); + }); +}); diff --git a/backend/shared/events/schemaVersioning.ts b/backend/shared/events/schemaVersioning.ts new file mode 100644 index 00000000..8a2bc727 --- /dev/null +++ b/backend/shared/events/schemaVersioning.ts @@ -0,0 +1,378 @@ +/** + * Event schema versioning (issue #1805). + * + * Event consumers break when producers change payload shapes. This module + * provides a versioned schema registry with explicit compatibility rules so + * that schema changes can be reviewed, classified, and — where compatible — + * accompanied by automatic payload migrations. + * + * Capabilities: + * - Register multiple versions of an event schema + * - Compute a structured diff between two versions + * - Classify a change as backward / forward / full / none compatible + * - Register migrators that transform payloads across versions + * - Validate a payload against a specific schema version + * + * Compatibility definitions (modeled on the Confluent schema registry): + * + * backward — consumers using the NEW schema can read data produced with + * the OLD schema (safe to upgrade consumers first). + * forward — consumers using the OLD schema can read data produced with + * the NEW schema (safe to upgrade producers first). + * full — both backward and forward compatible. + * none — a breaking change that requires coordinated rollout. + */ + +/** Primitive field types supported by the schema system. */ +export type FieldType = 'string' | 'number' | 'boolean' | 'object' | 'array'; + +/** Description of a single field within a versioned schema. */ +export interface SchemaField { + type: FieldType; + required: boolean; + description: string; + /** Default applied when migrating an older payload that lacks this field. */ + default?: unknown; + /** Marks the field as deprecated (still emitted, slated for removal). */ + deprecated?: boolean; +} + +/** A fully versioned schema for one event type. */ +export interface VersionedSchema { + type: string; + version: number; + fields: Record; +} + +/** The kind of compatibility a given change offers. */ +export type CompatibilityLevel = 'backward' | 'forward' | 'full' | 'none'; + +/** A single field-level change between two schema versions. */ +export interface FieldChange { + field: string; + change: + | 'added' + | 'removed' + | 'type-changed' + | 'required-added' + | 'required-removed' + | 'default-added' + | 'default-changed' + | 'description-changed'; + from?: SchemaField; + to?: SchemaField; +} + +/** Structured diff between two versions of a schema. */ +export interface SchemaDiff { + type: string; + fromVersion: number; + toVersion: number; + changes: FieldChange[]; + backwardCompatible: boolean; + forwardCompatible: boolean; + compatibility: CompatibilityLevel; + breaking: boolean; + /** Human-readable explanation of each incompatibility. */ + incompatibilities: string[]; +} + +/** Function that transforms a payload from `fromVersion` to `toVersion`. */ +export type Migrator = (payload: Record) => Record; + +/** + * Computes the structured diff and compatibility classification between two + * schema versions. + */ +export function diffSchemas( + type: string, + from: VersionedSchema, + to: VersionedSchema, +): SchemaDiff { + const changes: FieldChange[] = []; + const incompatibilities: string[] = []; + + const fromFields = from.fields; + const toFields = to.fields; + + // Fields present in `to` only → added. + for (const [name, toField] of Object.entries(toFields)) { + if (!(name in fromFields)) { + changes.push({ field: name, change: 'added', to: toField }); + // Backward: new consumer expects this field from old data. Breaking if + // required AND no default is available to fill the gap. + if (toField.required && toField.default === undefined) { + incompatibilities.push( + `Field "${name}" added as required without a default — old payloads (v${from.version}) will not contain it`, + ); + } + } + } + + // Fields present in `from` only → removed. + for (const [name, fromField] of Object.entries(fromFields)) { + if (!(name in toFields)) { + changes.push({ field: name, change: 'removed', from: fromField }); + // Forward: old consumer expects this field from new data. Breaking if + // the old consumer required it. + if (fromField.required) { + incompatibilities.push( + `Required field "${name}" removed — old consumers (v${from.version}) still expect it in new payloads`, + ); + } + } + } + + // Fields present in both → check for type/required/default/description changes. + for (const [name, fromField] of Object.entries(fromFields)) { + const toField = toFields[name]; + if (!toField) continue; + + if (fromField.type !== toField.type) { + changes.push({ field: name, change: 'type-changed', from: fromField, to: toField }); + incompatibilities.push( + `Field "${name}" type changed from "${fromField.type}" to "${toField.type}"`, + ); + continue; + } + + if (!fromField.required && toField.required) { + changes.push({ field: name, change: 'required-added', from: fromField, to: toField }); + // Backward: new consumer requires a field old data may omit. + if (toField.default === undefined) { + incompatibilities.push( + `Field "${name}" became required without a default — old payloads may omit it`, + ); + } + } else if (fromField.required && !toField.required) { + changes.push({ field: name, change: 'required-removed', from: fromField, to: toField }); + // Forward: old consumer still requires it; new producer may omit. + incompatibilities.push( + `Field "${name}" became optional — old consumers (v${from.version}) still require it`, + ); + } + + if (fromField.default === undefined && toField.default !== undefined) { + changes.push({ field: name, change: 'default-added', from: fromField, to: toField }); + } else if ( + fromField.default !== undefined && + toField.default !== undefined && + fromField.default !== toField.default + ) { + changes.push({ field: name, change: 'default-changed', from: fromField, to: toField }); + } + + if (fromField.description !== toField.description) { + changes.push({ field: name, change: 'description-changed', from: fromField, to: toField }); + } + } + + // Classify. A change is backward-compatible if the new schema can read old + // data, and forward-compatible if the old schema can read new data. + // Type changes break both directions; required-added (no default) breaks + // backward; removed-required and required-removed break forward. + const hasTypeChange = changes.some(c => c.change === 'type-changed'); + const breaksBackward = hasTypeChange || + changes.some(c => c.change === 'added' && c.to?.required && c.to?.default === undefined) || + changes.some(c => c.change === 'required-added' && c.to?.default === undefined); + const breaksForward = hasTypeChange || + changes.some(c => c.change === 'removed' && c.from?.required) || + changes.some(c => c.change === 'required-removed'); + + const backward = !breaksBackward; + const forward = !breaksForward; + const compatibility: CompatibilityLevel = backward && forward + ? 'full' + : backward + ? 'backward' + : forward + ? 'forward' + : 'none'; + + return { + type, + fromVersion: from.version, + toVersion: to.version, + changes, + backwardCompatible: backward, + forwardCompatible: forward, + compatibility, + breaking: compatibility === 'none', + incompatibilities, + }; +} + +/** + * Validates a payload against a specific schema version. + * Returns the list of validation errors (empty when valid). + */ +export function validateAgainstVersion( + schema: VersionedSchema, + payload: Record, +): string[] { + const errors: string[] = []; + for (const [name, spec] of Object.entries(schema.fields)) { + const value = payload[name]; + if (spec.required && (value === undefined || value === null)) { + errors.push(`Missing required field: ${name}`); + continue; + } + if (value === undefined || value === null) continue; + if (!checkFieldType(value, spec.type)) { + errors.push(`Field "${name}" expected type "${spec.type}", got "${typeof value}"`); + } + } + return errors; +} + +function checkFieldType(value: unknown, type: FieldType): boolean { + switch (type) { + case 'string': return typeof value === 'string'; + case 'number': return typeof value === 'number' && !Number.isNaN(value); + case 'boolean': return typeof value === 'boolean'; + case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value); + case 'array': return Array.isArray(value); + default: return false; + } +} + +/** + * Versioned schema registry. Stores every published version of each event + * type, computes compatibility between versions, and applies registered + * migrators to translate payloads across versions. + */ +export class SchemaVersionRegistry { + /** eventType → version → schema */ + private schemas = new Map>(); + /** `${eventType}:${fromVersion}->${toVersion}` → migrator */ + private migrators = new Map(); + + /** Register a schema version. Re-registering an existing version throws. */ + registerVersion(type: string, version: number, fields: Record): VersionedSchema { + const versions = this.schemas.get(type) ?? new Map(); + if (versions.has(version)) { + throw new Error(`Schema ${type} v${version} is already registered`); + } + const schema: VersionedSchema = { type, version, fields }; + versions.set(version, schema); + this.schemas.set(type, versions); + return schema; + } + + getVersion(type: string, version: number): VersionedSchema | undefined { + return this.schemas.get(type)?.get(version); + } + + getLatestVersion(type: string): VersionedSchema | undefined { + const versions = this.schemas.get(type); + if (!versions || versions.size === 0) return undefined; + let latest = -1; + for (const v of versions.keys()) if (v > latest) latest = v; + return versions.get(latest); + } + + getVersions(type: string): number[] { + const versions = this.schemas.get(type); + if (!versions) return []; + return Array.from(versions.keys()).sort((a, b) => a - b); + } + + /** Register a migrator translating payloads from `fromVersion` to `toVersion`. */ + registerMigrator(type: string, fromVersion: number, toVersion: number, migrator: Migrator): void { + if (fromVersion >= toVersion) { + throw new Error(`Migrator must upgrade: fromVersion (${fromVersion}) must be < toVersion (${toVersion})`); + } + this.migrators.set(`${type}:${fromVersion}->${toVersion}`, migrator); + } + + /** Compute the diff between two registered versions. */ + diff(type: string, fromVersion: number, toVersion: number): SchemaDiff { + const from = this.getVersion(type, fromVersion); + const to = this.getVersion(type, toVersion); + if (!from) throw new Error(`Schema ${type} v${fromVersion} is not registered`); + if (!to) throw new Error(`Schema ${type} v${toVersion} is not registered`); + return diffSchemas(type, from, to); + } + + /** + * Verify that upgrading from `fromVersion` to `toVersion` satisfies the + * required compatibility level. Throws with the incompatibility reasons when + * the requirement is not met. + */ + assertCompatible( + type: string, + fromVersion: number, + toVersion: number, + required: CompatibilityLevel, + ): void { + const d = this.diff(type, fromVersion, toVersion); + const ok = + required === 'none' ? true : + required === 'backward' ? d.backwardCompatible : + required === 'forward' ? d.forwardCompatible : + d.backwardCompatible && d.forwardCompatible; + if (!ok) { + throw new Error( + `Schema ${type} v${fromVersion}→v${toVersion} is not ${required}-compatible: ` + + d.incompatibilities.join('; '), + ); + } + } + + /** + * Migrate a payload from `fromVersion` up to `toVersion` by applying the + * registered migrators for each step. When no explicit migrator exists for a + * step, an automatic compatibility migration is applied: fields newly added + * as required (with a default) are filled, and removed/deprecated fields are + * dropped. + */ + migrate( + type: string, + payload: Record, + fromVersion: number, + toVersion: number, + ): Record { + if (fromVersion === toVersion) return { ...payload }; + if (fromVersion > toVersion) { + throw new Error(`Downgrade migration is not supported (v${fromVersion}→v${toVersion})`); + } + let current = { ...payload }; + let v = fromVersion; + while (v < toVersion) { + const next = v + 1; + const explicit = this.migrators.get(`${type}:${v}->${next}`); + if (explicit) { + current = explicit(current); + } else { + current = this.autoMigrate(type, current, v, next); + } + v = next; + } + return current; + } + + /** Apply defaults for newly-required fields and drop removed fields. */ + private autoMigrate( + type: string, + payload: Record, + fromVersion: number, + toVersion: number, + ): Record { + const from = this.getVersion(type, fromVersion); + const to = this.getVersion(type, toVersion); + if (!from || !to) { + throw new Error(`Missing schema ${type} v${fromVersion} or v${toVersion}`); + } + const out: Record = {}; + for (const [name, toField] of Object.entries(to.fields)) { + if (name in payload) { + out[name] = payload[name]; + } else if (toField.default !== undefined) { + out[name] = toField.default; + } else if (!toField.required) { + // optional field absent in old payload — leave unset + } + } + return out; + } +} diff --git a/docs/EVENT_SCHEMA_VERSIONING.md b/docs/EVENT_SCHEMA_VERSIONING.md new file mode 100644 index 00000000..656f0cf1 --- /dev/null +++ b/docs/EVENT_SCHEMA_VERSIONING.md @@ -0,0 +1,136 @@ +# Event Schema Versioning + +> Issue #1805 — event consumers break on change. This document and the +> `backend/shared/events/schemaVersioning` module establish versioned event +> schemas with explicit compatibility rules so changes can be reviewed, +> classified, and — where compatible — migrated automatically. + +## Why + +Webhook consumers and internal event-driven services depend on stable payload +shapes. When a producer renames a field, changes a type, or adds a required +field, downstream consumers silently break. Versioning every published schema +and classifying each change makes breakage an explicit, reviewable decision +instead of an accident. + +## Module + +`backend/shared/events/schemaVersioning.ts` exposes: + +| Export | Purpose | +|--------|---------| +| `SchemaVersionRegistry` | Stores every published version of each event type, diffs versions, asserts compatibility, and migrates payloads. | +| `diffSchemas(from, to)` | Computes a structured `SchemaDiff` (added/removed/changed fields + compatibility classification). | +| `validateAgainstVersion(schema, payload)` | Validates a payload against a specific schema version. | +| `VersionedSchema`, `SchemaField`, `SchemaDiff`, `CompatibilityLevel` | Types. | + +## Compatibility Levels + +| Level | Meaning | Safe rollout | +|-------|---------|--------------| +| `full` | New reads old **and** old reads new. | Upgrade producers and consumers in any order. | +| `backward` | New schema can read old data. | Upgrade consumers first, then producers. | +| `forward` | Old schema can read new data. | Upgrade producers first, then consumers. | +| `none` | Breaking change. | Coordinated rollout; bump major version; use migrators. | + +### Rules + +- **Adding an optional field** → `full` compatible. +- **Adding a required field with a default** → `full` compatible (default fills the gap for old payloads). +- **Adding a required field without a default** → `forward` only (breaks backward; old payloads lack the field). +- **Removing an optional field** → `full` compatible. +- **Removing a required field** → `backward` only (breaks forward; old consumers still expect it). +- **Changing a field type** → `none` (breaks both directions). +- **Optional → required (no default)** → breaks backward. +- **Required → optional** → breaks forward. + +## Usage + +### Register versions + +```ts +import { SchemaVersionRegistry } from '../shared/events/schemaVersioning'; + +const registry = new SchemaVersionRegistry(); + +registry.registerVersion('subscription.created', 1, { + id: { type: 'string', required: true, description: 'Event ID' }, + planId:{ type: 'string', required: true, description: 'Plan ID' }, +}); + +registry.registerVersion('subscription.created', 2, { + id: { type: 'string', required: true, description: 'Event ID' }, + planId: { type: 'string', required: true, description: 'Plan ID' }, + currency: { type: 'string', required: true, description: 'Token symbol', default: 'USDC' }, +}); +``` + +### Classify a change + +```ts +const diff = registry.diff('subscription.created', 1, 2); +// diff.compatibility === 'full' +// diff.breaking === false +// diff.incompatibilities === [] +``` + +### Enforce a compatibility policy on deploy + +```ts +// Refuse to ship a change that isn't backward-compatible. +registry.assertCompatible('subscription.created', 1, 2, 'backward'); +``` + +### Migrate payloads across versions + +```ts +// Automatic: fills defaults for newly-required fields, drops removed fields. +const v2Payload = registry.migrate('subscription.created', v1Payload, 1, 2); + +// Explicit: register a migrator when a field needs transformation. +registry.registerMigrator('subscription.created', 1, 2, (p) => ({ + ...p, + currency: p.currency ?? 'EUR', +})); +``` + +### Validate a payload against a version + +```ts +const errors = validateAgainstVersion( + registry.getVersion('subscription.created', 2)!, + payload, +); +if (errors.length) throw new Error(`Invalid payload: ${errors.join(', ')}`); +``` + +## Integration with the existing event catalog + +The existing `backend/services/webhook/eventCatalog.ts` already carries a +`version` field on every `EventDefinition` and deprecation/sunset metadata. +`SchemaVersionRegistry` extends that model by supporting **multiple** versions +per event type and computing compatibility between them. The catalog remains +the source of truth for the *current* schema; the registry is the source of +truth for the *history* of versions and the rules governing transitions. + +## Testing + +`backend/shared/events/__tests__/schemaVersioning.test.ts` covers: + +- Compatibility classification for every rule (add optional, add required ± + default, remove optional/required, type change, optional↔required). +- Default and description change detection. +- Payload validation (missing required, type mismatch, array/object types). +- Registry registration, duplicate rejection, version listing. +- `assertCompatible` enforcement for backward/forward/full. +- Auto-migration (default filling), explicit migrators, multi-step chains. +- Downgrade rejection and same-version no-op. + +## Acceptance criteria + +- [x] **Versioned** — `SchemaVersionRegistry` stores an arbitrary number of + versions per event type and exposes `getVersion`/`getLatestVersion`/ + `getVersions`. +- [x] **Compatibility tested** — 20+ test cases classify every change rule + and exercise migration chains. +- [x] **Documented** — this document, inline module docs, and usage examples.