Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/mxfx/src/api/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,39 @@ 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. 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.
1 change: 1 addition & 0 deletions packages/mxfx/src/api/schema/index.ts
Original file line number Diff line number Diff line change
@@ -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'
100 changes: 100 additions & 0 deletions packages/mxfx/src/api/schema/versioning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Schema } from 'effect'
import { describe, expect, expectTypeOf, test } from 'vitest'

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)

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<typeof schema.Type>().toEqualTypeOf<Readonly<{ roomId: string; stable: number }>>()
})

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<typeof schema.Type>().toEqualTypeOf<Readonly<{ roomId: string; experimental: boolean; promoted: string }>>()
})

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('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<typeof schema.Type>().toEqualTypeOf<
Readonly<{
roomId: string
stable?: number
experimental?: boolean
promoted?: string
}>
>()
})

test('models fields selected from a widened MSC array as optional', () => {
const mscs: ReadonlyArray<Msc> = ['MSC9999']
const schema = versionedStruct({ version: matrixVersion(1, 0), mscs })(fields)

expect(Reflect.ownKeys(schema.fields)).toEqual(['roomId', 'experimental'])
expectTypeOf<typeof schema.Type>().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)
})
})
235 changes: 235 additions & 0 deletions packages/mxfx/src/api/schema/versioning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
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<Version extends MatrixVersion = MatrixVersion, Mscs extends ReadonlyArray<Msc> = ReadonlyArray<Msc>> {
readonly version: Version
readonly mscs?: Mscs
}

export type FieldAvailability =
| {
readonly since: MatrixVersion
readonly unstable?: ReadonlyArray<Msc>
}
| {
readonly since?: never
readonly unstable: readonly [Msc, ...Array<Msc>]
}

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 extends Schema.Top, Availability extends FieldAvailability> = S & {
readonly [versionedFieldTypeId]: Availability
}

/** Construct a validated Matrix specification version. */
export const matrixVersion = <const Major extends number, const Minor extends number, const Patch extends number = 0>(
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 =
<const Availability extends FieldAvailability>(availability: Availability) =>
<S extends Schema.Top>(schema: S): VersionedField<S, Availability> =>
schema.annotate({ mxfxAvailability: availability }) as VersionedField<S, Availability>

/** Mark a struct field as introduced by a stable Matrix specification version. */
export const availableSince =
<const Version extends MatrixVersion>(version: Version) =>
<S extends Schema.Top>(schema: S): VersionedField<S, { readonly since: Version }> =>
available({ since: version })(schema)

/** Mark a struct field as available when a Matrix Spec Change is enabled. */
export const availableWith =
<const Feature extends Msc>(feature: Feature) =>
<S extends Schema.Top>(schema: S): VersionedField<S, { readonly unstable: readonly [Feature] }> =>
available({ unstable: [feature] })(schema)

type BuildTuple<N extends number, Acc extends ReadonlyArray<unknown> = readonly []> = number extends N
? ReadonlyArray<unknown>
: Acc['length'] extends N
? Acc
: BuildTuple<N, readonly [...Acc, unknown]>

type LessThanOrEqual<A extends number, B extends number> = number extends A | B
? boolean
: BuildTuple<B> extends readonly [...BuildTuple<A>, ...ReadonlyArray<unknown>]
? true
: false

type Equal<A extends number, B extends number> = A extends B ? (B extends A ? true : false) : false

type IsAtLeast<Current extends MatrixVersion, Required extends MatrixVersion> =
Equal<Current[0], Required[0]> extends true
? Equal<Current[1], Required[1]> extends true
? LessThanOrEqual<Required[2], Current[2]>
: LessThanOrEqual<Required[1], Current[1]>
: LessThanOrEqual<Required[0], Current[0]>

type SupportsAny<Mscs extends ReadonlyArray<Msc>, Required extends ReadonlyArray<Msc>> = Msc extends Mscs[number]
? boolean
: Extract<Mscs[number], Required[number]> extends never
? false
: true

type SupportsAnyCapability<Capabilities extends MatrixCapabilities, Required extends ReadonlyArray<Msc>> = 'mscs' extends keyof Capabilities
? SupportsAny<NonNullable<Capabilities['mscs']>, Required>
: false

type Or<Left extends boolean, Right extends boolean> = [Left] extends [true]
? true
: [Right] extends [true]
? true
: [Left] extends [false]
? Right
: [Right] extends [false]
? Left
: boolean

type IsAvailable<Capabilities extends MatrixCapabilities, Availability extends FieldAvailability> = Availability extends {
readonly since: infer Version extends MatrixVersion
}
? Or<
IsAtLeast<Capabilities['version'], Version>,
Availability extends { readonly unstable: infer Required extends ReadonlyArray<Msc> }
? SupportsAnyCapability<Capabilities, Required>
: false
>
: Availability extends { readonly unstable: infer Required extends ReadonlyArray<Msc> }
? SupportsAnyCapability<Capabilities, Required>
: false

type SelectFields<Fields extends Schema.Struct.Fields, Capabilities extends MatrixCapabilities> = {
readonly [Key in keyof Fields as Fields[Key] extends VersionedField<Schema.Top, infer Availability>
? IsAvailable<Capabilities, Availability> extends true
? Key

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep widened capabilities aligned with runtime

This key remapping treats every non-literal availability result as unsupported because a boolean result does not extend true. For example, with const caps: MatrixCapabilities = { version: matrixVersion(1, 2) }, a field gated since v1.1 is present in schema.fields at runtime, but typeof schema.Type contains only the ungated fields. The same mismatch affects MSC arrays stored as ReadonlyArray<Msc>. This is especially likely for capabilities discovered at startup, and contradicts the documentation claim that literals merely give the most precise type. Please either model unknown gates conservatively (for example as optional/union fields) or reject widened capabilities at the API boundary, and add type/runtime tests for a value typed as MatrixCapabilities.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c92395f. Availability is now modeled as true, false, or uncertain: literal capabilities retain exact required/omitted struct fields, while widened versions or MSC arrays expose undecidable gated fields as optional and keep the runtime fields map typed as a partial map of the original schemas. I also corrected widened ReadonlyArray handling so it no longer implies definite support for every MSC. The new regressions cover both widened MatrixCapabilities and widened MSC arrays at the type and runtime levels; package typecheck, focused tests (7/7), and the full package suite (78/78) pass.

: never
: Key]: Fields[Key]
}

type PossibleFields<Fields extends Schema.Struct.Fields, Capabilities extends MatrixCapabilities> = {
readonly [Key in keyof Fields as Fields[Key] extends VersionedField<Schema.Top, infer Availability>
? IsAvailable<Capabilities, Availability> extends false
? never
: Key
: Key]: Fields[Key]
}

type HasUncertainFields<Fields extends Schema.Struct.Fields, Capabilities extends MatrixCapabilities> = true extends {
[Key in keyof Fields]: Fields[Key] extends VersionedField<Schema.Top, infer Availability>
? boolean extends IsAvailable<Capabilities, Availability>
? true
: false
: false
}[keyof Fields]
? true
: false

type DynamicFields<Fields extends Schema.Struct.Fields, Capabilities extends MatrixCapabilities> = {
readonly [Key in keyof Fields as Fields[Key] extends VersionedField<Schema.Top, infer Availability>
? IsAvailable<Capabilities, Availability> extends true
? Key
: never
: Key]: Fields[Key]
} & {
readonly [Key in keyof Fields as Fields[Key] extends VersionedField<Schema.Top, infer Availability>
? boolean extends IsAvailable<Capabilities, Availability>
? Key
: never
: never]?: Fields[Key]
}

type Simplify<T> = { [Key in keyof T]: T[Key] } & {}

type DynamicType<Fields extends Schema.Struct.Fields, Capabilities extends MatrixCapabilities> = Simplify<
Schema.Struct.Type<SelectFields<Fields, Capabilities>> & {
readonly [Key in keyof Fields as Fields[Key] extends VersionedField<Schema.Top, infer Availability>
? boolean extends IsAvailable<Capabilities, Availability>
? Key
: never
: never]?: Fields[Key]['Type']
}
>

type DynamicEncoded<Fields extends Schema.Struct.Fields, Capabilities extends MatrixCapabilities> = Simplify<
Schema.Struct.Encoded<SelectFields<Fields, Capabilities>> & {
readonly [Key in keyof Fields as Fields[Key] extends VersionedField<Schema.Top, infer Availability>
? boolean extends IsAvailable<Capabilities, Availability>
? Key
: never
: never]?: Fields[Key]['Encoded']
}
>

type VersionedStruct<Fields extends Schema.Struct.Fields, Capabilities extends MatrixCapabilities> =
HasUncertainFields<Fields, Capabilities> extends true
? Schema.Codec<
DynamicType<Fields, Capabilities>,
DynamicEncoded<Fields, Capabilities>,
Schema.Struct.DecodingServices<PossibleFields<Fields, Capabilities>>,
Schema.Struct.EncodingServices<PossibleFields<Fields, Capabilities>>
> & { readonly fields: DynamicFields<Fields, Capabilities> }
: Schema.Struct<SelectFields<Fields, Capabilities>>

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 =
<const Capabilities extends MatrixCapabilities>(capabilities: Capabilities) =>
<const Fields extends Schema.Struct.Fields>(fields: Fields): VersionedStruct<Fields, Capabilities> => {
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<Fields, Capabilities>

return Schema.Struct(selected) as VersionedStruct<Fields, Capabilities>
}