From fb2ff91fd7717e9eedee7ab42d55fe4b7ad237e2 Mon Sep 17 00:00:00 2001 From: wouter Date: Fri, 17 Jul 2026 02:37:29 +0200 Subject: [PATCH 1/2] feat: add capability-aware API schemas --- packages/mxfx/src/api/readme.md | 35 ++++ packages/mxfx/src/api/schema/index.ts | 1 + .../mxfx/src/api/schema/versioning.test.ts | 58 +++++++ packages/mxfx/src/api/schema/versioning.ts | 153 ++++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 packages/mxfx/src/api/schema/versioning.test.ts create mode 100644 packages/mxfx/src/api/schema/versioning.ts diff --git a/packages/mxfx/src/api/readme.md b/packages/mxfx/src/api/readme.md index c3ab859..9baf003 100644 --- a/packages/mxfx/src/api/readme.md +++ b/packages/mxfx/src/api/readme.md @@ -149,3 +149,38 @@ Endpoints: - [ ] GET /\_matrix/client/v1/rooms/{roomId}/threads - [ ] PUT /\_matrix/client/v3/rooms/{roomId}/typing/{userId} - [ ] GET /\_matrix/client/v3/voip/turnServer + +# API schemas + +## Versioning + +Matrix response fields can be gated by the specification version and Matrix Spec Changes supported by a homeserver. A versioned struct +contains only supported fields at runtime, and its inferred TypeScript type contains the same fields: + +```ts +import { Schema } from 'effect' +import { Versioning } from 'mxfx/api/schema' + +const syncFields = { + nextBatch: Schema.String, + stableField: Versioning.availableSince(Versioning.matrixVersion(1, 2))(Schema.String), + promotedField: Versioning.available({ + since: Versioning.matrixVersion(1, 3), + unstable: ['MSC1234'], + })(Schema.Boolean), +} + +const capabilities = { + version: Versioning.matrixVersion(1, 2), + mscs: ['MSC1234'], +} as const + +const SyncResponse = Versioning.versionedStruct(capabilities)(syncFields) +// SyncResponse.Type includes nextBatch, stableField, and promotedField. +``` + +An MSC alternative represents the common transition from an unstable feature to a stable specification version. A field with both `since` +and `unstable` is included when either condition is satisfied. Use `availableWith` for fields that only have an unstable MSC gate. + +Capabilities can be stored by a static client or HTTP client after checking homeserver support at startup. Keeping the capability object +literal (`as const`) gives the most precise inferred response types. diff --git a/packages/mxfx/src/api/schema/index.ts b/packages/mxfx/src/api/schema/index.ts index 76c1f35..efceec0 100644 --- a/packages/mxfx/src/api/schema/index.ts +++ b/packages/mxfx/src/api/schema/index.ts @@ -1,3 +1,4 @@ export * as Error from './error.ts' export * as Common from './sync.ts' export * as EncodeCase from './encode-case.ts' +export * as Versioning from './versioning.ts' diff --git a/packages/mxfx/src/api/schema/versioning.test.ts b/packages/mxfx/src/api/schema/versioning.test.ts new file mode 100644 index 0000000..32dbea7 --- /dev/null +++ b/packages/mxfx/src/api/schema/versioning.test.ts @@ -0,0 +1,58 @@ +import { Schema } from 'effect' +import { describe, expect, expectTypeOf, test } from 'vitest' + +import { available, availableSince, availableWith, matrixVersion, versionedStruct } from './versioning.ts' + +const v1_1 = matrixVersion(1, 1) +const v1_2 = matrixVersion(1, 2) + +const fields = { + roomId: Schema.String, + stable: availableSince(v1_1)(Schema.Number), + experimental: availableWith('MSC9999')(Schema.Boolean), + promoted: available({ since: v1_2, unstable: ['MSC1234'] })(Schema.String), +} + +describe('Matrix API schema versioning', () => { + test('selects fields introduced by the configured stable version', () => { + const schema = versionedStruct({ version: v1_1 })(fields) + + expect(Reflect.ownKeys(schema.fields)).toEqual(['roomId', 'stable']) + expect(Schema.decodeUnknownSync(schema)({ roomId: '!room:example.org', stable: 1 })).toEqual({ + roomId: '!room:example.org', + stable: 1, + }) + + expectTypeOf().toEqualTypeOf>() + }) + + test('selects fields enabled by an MSC', () => { + const schema = versionedStruct({ version: matrixVersion(1, 0), mscs: ['MSC9999', 'MSC1234'] })(fields) + + expect(Reflect.ownKeys(schema.fields)).toEqual(['roomId', 'experimental', 'promoted']) + expectTypeOf().toEqualTypeOf>() + }) + + test('includes a promoted field through either its stable version or MSC', () => { + const stableSchema = versionedStruct({ version: v1_2 })(fields) + const unstableSchema = versionedStruct({ version: matrixVersion(1, 0), mscs: ['MSC1234'] })(fields) + + expect(Reflect.ownKeys(stableSchema.fields)).toContain('promoted') + expect(Reflect.ownKeys(unstableSchema.fields)).toContain('promoted') + }) + + test('uses patch versions when selecting fields', () => { + const patchFields = { + always: Schema.String, + later: availableSince(matrixVersion(1, 2, 3))(Schema.String), + } + + expect(Reflect.ownKeys(versionedStruct({ version: matrixVersion(1, 2, 2) })(patchFields).fields)).toEqual(['always']) + expect(Reflect.ownKeys(versionedStruct({ version: matrixVersion(1, 2, 3) })(patchFields).fields)).toEqual(['always', 'later']) + }) + + test('rejects invalid version components', () => { + expect(() => matrixVersion(1, -1)).toThrow(RangeError) + expect(() => matrixVersion(1, 1.5)).toThrow(RangeError) + }) +}) diff --git a/packages/mxfx/src/api/schema/versioning.ts b/packages/mxfx/src/api/schema/versioning.ts new file mode 100644 index 0000000..d6431d5 --- /dev/null +++ b/packages/mxfx/src/api/schema/versioning.ts @@ -0,0 +1,153 @@ +import { Schema } from 'effect' + +/** A Matrix specification version, represented as `[major, minor, patch]`. */ +export type MatrixVersion = readonly [major: number, minor: number, patch: number] + +/** The identifier of a Matrix Spec Change. */ +export type Msc = `MSC${number}` + +/** + * Capabilities supported by a homeserver. + * + * Keep this value literal (`as const`) to have unavailable fields removed from + * both the schema and its inferred TypeScript type. + */ +export interface MatrixCapabilities = ReadonlyArray> { + readonly version: Version + readonly mscs?: Mscs +} + +export type FieldAvailability = + | { + readonly since: MatrixVersion + readonly unstable?: ReadonlyArray + } + | { + readonly since?: never + readonly unstable: readonly [Msc, ...Array] + } + +declare module 'effect/Schema' { + namespace Annotations { + interface Annotations { + /** Matrix versions or unstable features in which a schema field exists. */ + readonly mxfxAvailability?: FieldAvailability | undefined + } + } +} + +declare const versionedFieldTypeId: unique symbol + +/** A field schema carrying Matrix availability metadata. */ +export type VersionedField = S & { + readonly [versionedFieldTypeId]: Availability +} + +/** Construct a validated Matrix specification version. */ +export const matrixVersion = ( + major: Major, + minor: Minor, + patch: Patch = 0 as Patch, +): readonly [Major, Minor, Patch] => { + if (![major, minor, patch].every(part => Number.isSafeInteger(part) && part >= 0)) { + throw new RangeError('Matrix version components must be non-negative integers') + } + + return [major, minor, patch] +} + +/** + * Mark a struct field as available in a stable version and/or behind one of + * the listed MSCs. Stable and unstable alternatives use OR semantics. + */ +export const available = + (availability: Availability) => + (schema: S): VersionedField => + schema.annotate({ mxfxAvailability: availability }) as VersionedField + +/** Mark a struct field as introduced by a stable Matrix specification version. */ +export const availableSince = + (version: Version) => + (schema: S): VersionedField => + available({ since: version })(schema) + +/** Mark a struct field as available when a Matrix Spec Change is enabled. */ +export const availableWith = + (feature: Feature) => + (schema: S): VersionedField => + available({ unstable: [feature] })(schema) + +type BuildTuple = readonly []> = number extends N + ? ReadonlyArray + : Acc['length'] extends N + ? Acc + : BuildTuple + +type LessThanOrEqual = number extends A | B + ? boolean + : BuildTuple extends readonly [...BuildTuple, ...ReadonlyArray] + ? true + : false + +type Equal = A extends B ? (B extends A ? true : false) : false + +type IsAtLeast = + Equal extends true + ? Equal extends true + ? LessThanOrEqual + : LessThanOrEqual + : LessThanOrEqual + +type SupportsAny, Required extends ReadonlyArray> = + Extract extends never ? false : true + +type IsAvailable = Availability extends { + readonly since: infer Version extends MatrixVersion +} + ? IsAtLeast extends true + ? true + : Availability extends { readonly unstable: infer Required extends ReadonlyArray } + ? SupportsAny, Required> + : false + : Availability extends { readonly unstable: infer Required extends ReadonlyArray } + ? SupportsAny, Required> + : false + +type SelectFields = { + readonly [Key in keyof Fields as Fields[Key] extends VersionedField + ? IsAvailable extends true + ? Key + : never + : Key]: Fields[Key] +} + +const isAvailable = (capabilities: MatrixCapabilities, availability: FieldAvailability): boolean => { + const [major, minor, patch] = capabilities.version + const stable = availability.since + ? major > availability.since[0] || + (major === availability.since[0] && + (minor > availability.since[1] || (minor === availability.since[1] && patch >= availability.since[2]))) + : false + const unstable = availability.unstable?.some(feature => capabilities.mscs?.includes(feature)) ?? false + + return stable || unstable +} + +/** + * Build a struct schema containing exactly the fields supported by the supplied + * Matrix capabilities. Unannotated fields are always included. + */ +export const versionedStruct = + (capabilities: Capabilities) => + (fields: Fields): Schema.Struct> => { + const selected = Object.fromEntries( + Reflect.ownKeys(fields).flatMap(key => { + const field = fields[key] + const availability = field === undefined ? undefined : Schema.resolveAnnotations(field)?.mxfxAvailability + + return availability === undefined || isAvailable(capabilities, availability) ? [[key, field]] : [] + }), + ) as SelectFields + + return Schema.Struct(selected) + } From c92395f5ace8c6fb2d2da1593c90c1b40a784591 Mon Sep 17 00:00:00 2001 From: wouter Date: Fri, 17 Jul 2026 02:54:09 +0200 Subject: [PATCH 2/2] fix: align widened capability schema types --- packages/mxfx/src/api/readme.md | 3 +- .../mxfx/src/api/schema/versioning.test.ts | 44 +++++++- packages/mxfx/src/api/schema/versioning.ts | 102 ++++++++++++++++-- 3 files changed, 137 insertions(+), 12 deletions(-) diff --git a/packages/mxfx/src/api/readme.md b/packages/mxfx/src/api/readme.md index 9baf003..1fea4a1 100644 --- a/packages/mxfx/src/api/readme.md +++ b/packages/mxfx/src/api/readme.md @@ -183,4 +183,5 @@ An MSC alternative represents the common transition from an unstable feature to and `unstable` is included when either condition is satisfied. Use `availableWith` for fields that only have an unstable MSC gate. Capabilities can be stored by a static client or HTTP client after checking homeserver support at startup. Keeping the capability object -literal (`as const`) gives the most precise inferred response types. +literal (`as const`) gives the most precise inferred response types. When a version or MSC list is widened, gated fields whose availability +cannot be decided statically are inferred as optional instead; the schema still selects them from the concrete capabilities at runtime. diff --git a/packages/mxfx/src/api/schema/versioning.test.ts b/packages/mxfx/src/api/schema/versioning.test.ts index 32dbea7..dc69251 100644 --- a/packages/mxfx/src/api/schema/versioning.test.ts +++ b/packages/mxfx/src/api/schema/versioning.test.ts @@ -1,7 +1,15 @@ import { Schema } from 'effect' import { describe, expect, expectTypeOf, test } from 'vitest' -import { available, availableSince, availableWith, matrixVersion, versionedStruct } from './versioning.ts' +import { + available, + availableSince, + availableWith, + matrixVersion, + type MatrixCapabilities, + type Msc, + versionedStruct, +} from './versioning.ts' const v1_1 = matrixVersion(1, 1) const v1_2 = matrixVersion(1, 2) @@ -51,6 +59,40 @@ describe('Matrix API schema versioning', () => { expect(Reflect.ownKeys(versionedStruct({ version: matrixVersion(1, 2, 3) })(patchFields).fields)).toEqual(['always', 'later']) }) + test('models fields selected from widened capabilities as optional', () => { + const capabilities: MatrixCapabilities = { version: v1_2 } + const schema = versionedStruct(capabilities)(fields) + + expect(Reflect.ownKeys(schema.fields)).toEqual(['roomId', 'stable', 'promoted']) + expect(Schema.decodeUnknownSync(schema)({ roomId: '!room:example.org', stable: 1, promoted: 'stable' })).toEqual({ + roomId: '!room:example.org', + stable: 1, + promoted: 'stable', + }) + expectTypeOf().toEqualTypeOf< + Readonly<{ + roomId: string + stable?: number + experimental?: boolean + promoted?: string + }> + >() + }) + + test('models fields selected from a widened MSC array as optional', () => { + const mscs: ReadonlyArray = ['MSC9999'] + const schema = versionedStruct({ version: matrixVersion(1, 0), mscs })(fields) + + expect(Reflect.ownKeys(schema.fields)).toEqual(['roomId', 'experimental']) + expectTypeOf().toEqualTypeOf< + Readonly<{ + roomId: string + experimental?: boolean + promoted?: string + }> + >() + }) + test('rejects invalid version components', () => { expect(() => matrixVersion(1, -1)).toThrow(RangeError) expect(() => matrixVersion(1, 1.5)).toThrow(RangeError) diff --git a/packages/mxfx/src/api/schema/versioning.ts b/packages/mxfx/src/api/schema/versioning.ts index d6431d5..dac5127 100644 --- a/packages/mxfx/src/api/schema/versioning.ts +++ b/packages/mxfx/src/api/schema/versioning.ts @@ -98,19 +98,37 @@ type IsAtLeast = : LessThanOrEqual : LessThanOrEqual -type SupportsAny, Required extends ReadonlyArray> = - Extract extends never ? false : true +type SupportsAny, Required extends ReadonlyArray> = Msc extends Mscs[number] + ? boolean + : Extract extends never + ? false + : true + +type SupportsAnyCapability> = 'mscs' extends keyof Capabilities + ? SupportsAny, Required> + : false + +type Or = [Left] extends [true] + ? true + : [Right] extends [true] + ? true + : [Left] extends [false] + ? Right + : [Right] extends [false] + ? Left + : boolean type IsAvailable = Availability extends { readonly since: infer Version extends MatrixVersion } - ? IsAtLeast extends true - ? true - : Availability extends { readonly unstable: infer Required extends ReadonlyArray } - ? SupportsAny, Required> - : false + ? Or< + IsAtLeast, + Availability extends { readonly unstable: infer Required extends ReadonlyArray } + ? SupportsAnyCapability + : false + > : Availability extends { readonly unstable: infer Required extends ReadonlyArray } - ? SupportsAny, Required> + ? SupportsAnyCapability : false type SelectFields = { @@ -121,6 +139,70 @@ type SelectFields = { + readonly [Key in keyof Fields as Fields[Key] extends VersionedField + ? IsAvailable extends false + ? never + : Key + : Key]: Fields[Key] +} + +type HasUncertainFields = true extends { + [Key in keyof Fields]: Fields[Key] extends VersionedField + ? boolean extends IsAvailable + ? true + : false + : false +}[keyof Fields] + ? true + : false + +type DynamicFields = { + readonly [Key in keyof Fields as Fields[Key] extends VersionedField + ? IsAvailable extends true + ? Key + : never + : Key]: Fields[Key] +} & { + readonly [Key in keyof Fields as Fields[Key] extends VersionedField + ? boolean extends IsAvailable + ? Key + : never + : never]?: Fields[Key] +} + +type Simplify = { [Key in keyof T]: T[Key] } & {} + +type DynamicType = Simplify< + Schema.Struct.Type> & { + readonly [Key in keyof Fields as Fields[Key] extends VersionedField + ? boolean extends IsAvailable + ? Key + : never + : never]?: Fields[Key]['Type'] + } +> + +type DynamicEncoded = Simplify< + Schema.Struct.Encoded> & { + readonly [Key in keyof Fields as Fields[Key] extends VersionedField + ? boolean extends IsAvailable + ? Key + : never + : never]?: Fields[Key]['Encoded'] + } +> + +type VersionedStruct = + HasUncertainFields extends true + ? Schema.Codec< + DynamicType, + DynamicEncoded, + Schema.Struct.DecodingServices>, + Schema.Struct.EncodingServices> + > & { readonly fields: DynamicFields } + : Schema.Struct> + const isAvailable = (capabilities: MatrixCapabilities, availability: FieldAvailability): boolean => { const [major, minor, patch] = capabilities.version const stable = availability.since @@ -139,7 +221,7 @@ const isAvailable = (capabilities: MatrixCapabilities, availability: FieldAvaila */ export const versionedStruct = (capabilities: Capabilities) => - (fields: Fields): Schema.Struct> => { + (fields: Fields): VersionedStruct => { const selected = Object.fromEntries( Reflect.ownKeys(fields).flatMap(key => { const field = fields[key] @@ -149,5 +231,5 @@ export const versionedStruct = }), ) as SelectFields - return Schema.Struct(selected) + return Schema.Struct(selected) as VersionedStruct }