From 654124ab6d2ac580b58b7d2592cc6beecffa49d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 03:49:57 +0000 Subject: [PATCH] =?UTF-8?q?refactor(client)!:=20subscribeMetadata=20?= =?UTF-8?q?=E7=9A=84=20type=20=E6=94=B6=E7=AA=84=E4=B8=BA=20MetadataEventS?= =?UTF-8?q?ubject=20(#4627)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4602 已把生产端钉成 declared = enforced —— MetadataEventType 枚举外的 metadata 类型不发布任何 realtime 事件。消费端却仍是宽的 string,于是 subscribeMetadata('translation', cb) 编译全绿、运行永盲。 本次把消费端也钉上:新增 spec 派生类型 MetadataEventSubject(从 MetadataEventType 用模板字面量 + 分发式条件类型解出 {type} 半边,不是 重抄一份),并收窄三处签名 —— client 的 subscribeMetadata、client-react 的 useMetadataSubscription / useMetadataSubscriptionCallback。 轴 2(扩枚举覆盖面)不预答,枚举一个成员都没动。 Refs #4627 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .../subscribe-metadata-event-subject.md | 56 +++++++++++ .../client-react/src/realtime-hooks.test.tsx | 16 +++- packages/client-react/src/realtime-hooks.tsx | 21 +++- packages/client/src/realtime-api.test.ts | 96 +++++++++++++++++++ packages/client/src/realtime-api.ts | 37 +++++-- packages/spec/api-surface/api.json | 1 + packages/spec/src/api/events.test.ts | 85 ++++++++++++++++ packages/spec/src/api/events.zod.ts | 56 +++++++++++ 8 files changed, 353 insertions(+), 15 deletions(-) create mode 100644 .changeset/subscribe-metadata-event-subject.md diff --git a/.changeset/subscribe-metadata-event-subject.md b/.changeset/subscribe-metadata-event-subject.md new file mode 100644 index 0000000000..f4d2f50b7d --- /dev/null +++ b/.changeset/subscribe-metadata-event-subject.md @@ -0,0 +1,56 @@ +--- +"@objectstack/spec": minor +"@objectstack/client": major +"@objectstack/client-react": major +--- + +refactor(client)!: `subscribeMetadata` 的 `type` 收窄为 `MetadataEventSubject`,订阅一个合同上永远不会来的事件改为编译报错 (#4627) + +`MetadataEventType` 是一个封闭枚举:13 个 metadata 类型 × 3 个动作。#4602 已经把生产端钉成 declared = enforced —— 枚举外的类型(`translation`、`datasource`、`page`、`hook`、`trigger`、`validation` 等,全都是 `DEFAULT_METADATA_TYPE_REGISTRY` 里可注册的真实类型)**不发布**任何 realtime 事件,因为不存在能合法交付给 `(event: MetadataEvent) => void` 回调的事件形状。 + +消费端却一直是宽的 `string`。于是 `client.events.subscribeMetadata('translation', cb)` 编译全绿、运行永盲:回调永远不会被调用,而类型系统一个字都没说。这正是 AI 写订阅代码最容易踩的形状 —— 它看起来订阅上了。 + +本次把消费端也钉上,两端对齐后这种代码写不出来。 + +**新增导出**:`@objectstack/spec/api` 的 `MetadataEventSubject` —— `metadata.{type}.{action}` 的 `{type}` 半边,`'object' | 'field' | 'view' | …`。它是从 `MetadataEventType` **派生**的(模板字面量 + 分发式条件类型),不是在旁边重抄一份,所以两者不可能各说各话:枚举加一个成员,这个联合自动跟着长。`check:api-surface` 记录为 0 breaking / 1 added。 + +**签名收窄**(三处,全部只是把 `string` 换成这个联合): + +- `@objectstack/client` 的 `RealtimeAPI.subscribeMetadata(type, …)` +- `@objectstack/client-react` 的 `useMetadataSubscription(type, …)` +- `@objectstack/client-react` 的 `useMetadataSubscriptionCallback(type, …)` + +**FROM → TO —— 原来传 `string` 的代码怎么改** + +枚举内的字面量一个字都不用动,本仓 6 处调用点(`'object'`)零迁移: + +```ts +// 照常编译,没有变化 +client.events.subscribeMetadata('object', onEvent); +useMetadataSubscription('view'); +``` + +真正被拒绝的只有两种写法,各有各的一行修复: + +```ts +// FROM —— 变量声明成了宽的 string +const type: string = route.params.metaType; +client.events.subscribeMetadata(type, onEvent); // TS2345 + +// TO —— 把变量(或 state、或路由参数)的类型改成这个联合 +import type { MetadataEventSubject } from '@objectstack/spec/api'; +const type: MetadataEventSubject = 'object'; +client.events.subscribeMetadata(type, onEvent); +``` + +```ts +// FROM —— 订阅一个没有 realtime 合同的类型 +client.events.subscribeMetadata('translation', onEvent); // TS2345 + +// TO —— 删掉它。这段代码从 #4602 起就收不到任何事件, +// 编译器现在说的是它一直以来的运行时事实,不是新增的限制。 +``` + +编译器会把每一处指出来,错误码都是 **TS2345**(`Argument of type '"translation"' is not assignable to parameter of type 'MetadataEventSubject'`)。**运行时行为零变化** —— 被拒绝的调用本来就收不到事件,标 major 是因为这是源码级破坏性变更(#5181 的同一条先例:源码级破坏、运行时不变,仍走 major)。 + +**本次不做、也不预答的**:哪些可注册类型「应该」有 realtime 事件,是 #4627 的轴 2 —— 一个由真实需求驱动的产品覆盖面问题(例如 #4426 的 flow/workflow i18n 若落地会把 `translation` 推上来)。枚举没有动一个成员。派生关系保证了这件事将来只需要改一处:枚举加三个名字,两端同时跟上。 diff --git a/packages/client-react/src/realtime-hooks.test.tsx b/packages/client-react/src/realtime-hooks.test.tsx index d96a73529f..adfa29828a 100644 --- a/packages/client-react/src/realtime-hooks.test.tsx +++ b/packages/client-react/src/realtime-hooks.test.tsx @@ -32,7 +32,12 @@ import * as React from 'react'; import { describe, it, expect, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import type { ObjectStackClient } from '@objectstack/client'; -import type { BulkDataEvent, DataEvent, MetadataEvent } from '@objectstack/spec/api'; +import type { + BulkDataEvent, + DataEvent, + MetadataEvent, + MetadataEventSubject, +} from '@objectstack/spec/api'; import { ObjectStackProvider } from './context'; import { useAutoRefresh, @@ -209,7 +214,14 @@ describe('#4682 dependency arrays drive re-subscription', () => { ({ type, options }) => useMetadataSubscription(type, options), { wrapper: wrapperFor(client), - initialProps: { type: 'object', options: { packageId: 'crm' } }, + // Annotated, not widened: `renderHook` infers the prop type from this + // literal, and a bare `'object'` widens to `string` — which the hook + // no longer accepts (#4627). `rerender({ type: 'view' })` below is + // exactly why the annotation must be the union rather than the literal. + initialProps: { + type: 'object' as MetadataEventSubject, + options: { packageId: 'crm' }, + }, } ); diff --git a/packages/client-react/src/realtime-hooks.tsx b/packages/client-react/src/realtime-hooks.tsx index 9135e6c827..a29359937f 100644 --- a/packages/client-react/src/realtime-hooks.tsx +++ b/packages/client-react/src/realtime-hooks.tsx @@ -8,14 +8,24 @@ */ import { useEffect, useState } from 'react'; -import type { MetadataEvent, DataEvent, BulkDataEvent } from '@objectstack/spec/api'; +import type { + MetadataEvent, + MetadataEventSubject, + DataEvent, + BulkDataEvent, +} from '@objectstack/spec/api'; import { useClient } from './context'; import { useEventCallback } from './internal-deps'; /** * Hook to subscribe to metadata events * - * @param type - Metadata type to subscribe to (e.g., 'object', 'view', 'agent') + * @param type - Metadata type to subscribe to (e.g., 'object', 'view', 'agent'). + * Typed {@link MetadataEventSubject}, the closed set derived from + * `MetadataEventType` (#4627) — a metadata type with no realtime event + * contract (`'translation'`, `'datasource'`, …) is a compile error here + * rather than a subscription that never fires. This hook only forwards the + * argument to `subscribeMetadata`, so it must not be the looser of the two. * @param options - Optional filters (packageId) * @returns Latest metadata event or null * @@ -36,7 +46,7 @@ import { useEventCallback } from './internal-deps'; * ``` */ export function useMetadataSubscription( - type: string, + type: MetadataEventSubject, options?: { packageId?: string } ): MetadataEvent | null { const client = useClient(); @@ -112,7 +122,8 @@ export function useDataSubscription( * This variant doesn't store events in state, it just triggers a callback. * Useful for triggering refetches or side effects without re-renders. * - * @param type - Metadata type to subscribe to + * @param type - Metadata type to subscribe to. Same {@link MetadataEventSubject} + * narrowing as {@link useMetadataSubscription} (#4627). * @param callback - Callback to invoke on events * @param options - Optional filters * @@ -130,7 +141,7 @@ export function useDataSubscription( * ``` */ export function useMetadataSubscriptionCallback( - type: string, + type: MetadataEventSubject, callback: (event: MetadataEvent) => void, options?: { packageId?: string } ): void { diff --git a/packages/client/src/realtime-api.test.ts b/packages/client/src/realtime-api.test.ts index b4a6a591d6..531aadede2 100644 --- a/packages/client/src/realtime-api.test.ts +++ b/packages/client/src/realtime-api.test.ts @@ -21,6 +21,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { RealtimeEventPayload } from '@objectstack/spec/contracts'; +import type { MetadataEvent, MetadataEventSubject } from '@objectstack/spec/api'; import { RealtimeAPI } from './realtime-api'; const VALID_EVENT = { @@ -138,3 +139,98 @@ describe('#4602 — RealtimeAPI.subscribeMetadata contract boundary', () => { expect(callback).toHaveBeenCalledTimes(1); }); }); + +// =========================================================================== +// #4627 — the `type` parameter is the closed MetadataEventSubject, not string +// =========================================================================== +// +// Every pin below is resolved by tsc, not by vitest. `packages/client`'s +// `typecheck` script compiles this file through `tsconfig.test.json`, and +// `test-typecheck-debt.json` carries NO entry for `src/realtime-api.test.ts` — +// which, under that ledger's "a file not listed here may have no errors at all" +// rule, makes zero the measurable baseline these pins move away from. Reverting +// the parameter to `string` leaves the `@ts-expect-error` directives unused, +// and an unused directive is itself an error (TS2578), so the revert is red. +// +// Reverse verification, direction declared before running it: `type: string` +// restored → the two `@ts-expect-error` lines below go red as TS2578 "Unused +// '@ts-expect-error' directive", and `ParameterIsExactlySubject` resolves to +// `never` so its initializer goes red as TS2322. Both were measured and both +// landed. A fourth red was NOT predicted and is recorded here rather than +// tidied away: `realtime-api.ts`'s own `const eventTypes: MetadataEventType[]` +// goes red too (three TS2322, `` `metadata.${string}.created` `` not assignable +// to the enum), because a widened `type` makes the composed names unprovable. +// The implementation carries a pin of its own, one level below the signature. +// +// The `@ts-expect-error` directives are deliberately NOT the only pin. A bare +// directive passes on ANY error at that line — the phantom-check hazard — so +// each is corroborated by a type-level assertion that fixes exactly WHICH +// error it can be: the parameter type is pinned to `MetadataEventSubject` +// exactly, and the positive cases below prove the callback/options arms of the +// same signature still compile. What is left for the directive to catch can +// then only be argument one (TS2345 — the code recorded in each comment, +// measured by deleting the directive and reading tsc's output). + +describe('#4627 — subscribeMetadata narrows `type` to the event vocabulary', () => { + const api = new RealtimeAPI('http://localhost:3000'); + const callback = (_event: MetadataEvent): void => undefined; + + it('declares the parameter as exactly MetadataEventSubject', () => { + // Read off the METHOD, not off the alias: a revert that widened the + // signature back to `string` while leaving `MetadataEventSubject` exported + // would sail past any alias-scoped assertion. + // + // Both directions are asserted, and each catches a different regression: + // - `Param extends MetadataEventSubject` fails on a re-widening + // (`string extends MetadataEventSubject` is false); + // - `MetadataEventSubject extends Param` fails on an over-narrowing to a + // subset (`… extends 'object'` is false). + // Together they are exactness; either alone is not. + type Param = Parameters[0]; + type ParameterIsExactlySubject = + Param extends MetadataEventSubject + ? MetadataEventSubject extends Param + ? 'exact' + : never + : never; + const exact: ParameterIsExactlySubject = 'exact'; + expect(exact).toBe('exact'); + }); + + it('accepts every member of the vocabulary, not just the one this file uses', () => { + // The other 12 subjects compile too — this is a union, not `'object'`. + const offs = [ + api.subscribeMetadata('field', callback), + api.subscribeMetadata('view', callback), + api.subscribeMetadata('permission', callback, { packageId: 'com.acme' }), + ]; + expect(offs).toHaveLength(3); + for (const off of offs) off(); + api.disconnect(); + }); + + it('rejects a metadata type that has no realtime event contract', () => { + // `translation` IS registrable (`DEFAULT_METADATA_TYPE_REGISTRY`) but has + // no `metadata.translation.*` name in `MetadataEventType`, so #4602's + // producer publishes nothing for it. Before #4627 this line compiled and + // the callback waited forever. + // @ts-expect-error TS2345 - '"translation"' is not assignable to parameter of type 'MetadataEventSubject' + const off = api.subscribeMetadata('translation', callback); + expect(off).toBeTypeOf('function'); + off(); + api.disconnect(); + }); + + it('rejects a plain `string` variable — the one real migration break', () => { + // This is what a 17.x caller has to change: a `string`-typed variable no + // longer flows in. The fix is to type the variable (or the state, or the + // route param) as `MetadataEventSubject`; there is no runtime change to + // accompany it, because the runtime already ignored these subscriptions. + const fromConfig: string = 'object'; + // @ts-expect-error TS2345 - 'string' is not assignable to parameter of type 'MetadataEventSubject' + const off = api.subscribeMetadata(fromConfig, callback); + expect(off).toBeTypeOf('function'); + off(); + api.disconnect(); + }); +}); diff --git a/packages/client/src/realtime-api.ts b/packages/client/src/realtime-api.ts index 3261c3b55b..ccde0192ce 100644 --- a/packages/client/src/realtime-api.ts +++ b/packages/client/src/realtime-api.ts @@ -13,6 +13,8 @@ import { DataEventSchema, BulkDataEventSchema, type MetadataEvent, + type MetadataEventSubject, + type MetadataEventType, type DataEvent, type BulkDataEvent, } from '@objectstack/spec/api'; @@ -53,25 +55,44 @@ export class RealtimeAPI { } /** - * Subscribe to metadata events - * Returns an unsubscribe function + * Subscribe to metadata events for one metadata type. + * Returns an unsubscribe function. + * + * `type` is {@link MetadataEventSubject} — the `{type}` half of the + * `metadata.{type}.{action}` vocabulary, derived from `MetadataEventType` — + * not a free string (#4627). The producer publishes nothing for a metadata + * type outside that enum (#4602 pinned that as declared = enforced), so a + * `string` parameter let a caller write `subscribeMetadata('translation', …)`, + * compile green, and wait forever on a callback the contract guarantees will + * never fire. Now the compiler says so at the call site. + * + * The narrowing does NOT decide which types deserve realtime events — that is + * axis 2 of #4627 and stays open. It only stops the consumer from claiming a + * coverage the producer never promised. */ subscribeMetadata( - type: string, + type: MetadataEventSubject, callback: (event: MetadataEvent) => void, options?: { packageId?: string } ): () => void { const subscriptionId = `metadata-${type}-${Date.now()}`; + // Annotated `MetadataEventType[]`, not left to widen to `string[]`: with a + // narrowed `type` these three templates are provably members of the enum, + // and saying so makes tsc re-check the composition. A typo here + // (`metadata.${type}.create`) used to be a silent no-match subscription — + // the same defect class one level down from the one the parameter fixes. + const eventTypes: MetadataEventType[] = [ + `metadata.${type}.created`, + `metadata.${type}.updated`, + `metadata.${type}.deleted`, + ]; + this.subscriptions.set(subscriptionId, { filter: { type, packageId: options?.packageId, - eventTypes: [ - `metadata.${type}.created`, - `metadata.${type}.updated`, - `metadata.${type}.deleted` - ] + eventTypes }, handler: (event) => { if (!event.type.startsWith('metadata.')) return; diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index edcd7dc403..0404d69118 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -526,6 +526,7 @@ "MetadataEndpointsConfigSchema (const)", "MetadataEvent (type)", "MetadataEventSchema (const)", + "MetadataEventSubject (type)", "MetadataEventType (type)", "MetadataExistsResponse (type)", "MetadataExistsResponseSchema (const)", diff --git a/packages/spec/src/api/events.test.ts b/packages/spec/src/api/events.test.ts index e4d6c02d02..f0214b42b5 100644 --- a/packages/spec/src/api/events.test.ts +++ b/packages/spec/src/api/events.test.ts @@ -4,6 +4,7 @@ import { DataEventType, MetadataEventSchema, DataEventSchema, + type MetadataEventSubject, } from './events.zod'; // Coverage added with the v17 dual-source cleanup (#4587): ./api is now the @@ -51,6 +52,90 @@ describe('MetadataEventType', () => { }); }); +// =========================================================================== +// MetadataEventSubject — the `{type}` half, derived rather than restated (#4627) +// =========================================================================== +// +// `@objectstack/client`'s `subscribeMetadata(type)` and the `client-react` +// hooks that delegate to it take THIS type. Its whole value is that it cannot +// disagree with the enum above, so the pins below are split in two on purpose: +// +// - the tsc-resolved ones prove the DERIVATION is faithful (nothing lost, +// nothing invented, and the split recomposes back to the enum exactly); +// - the vitest-run one binds the hand-written list of 13 names to the enum's +// runtime data, so a member added to `MetadataEventType` cannot leave the +// type pins asserting a stale vocabulary while still passing. +// +// This file carries no entry in `test-typecheck-debt.json`, which is what makes +// "zero tsc errors" the baseline the type-level pins move away from. The +// `expect()` calls only give the type assertions a home vitest will run. + +describe('MetadataEventSubject', () => { + /** The 13 metadata types that have a realtime event contract today. */ + const COVERED = [ + 'object', 'field', 'view', 'app', 'agent', 'tool', 'flow', + 'action', 'workflow', 'dashboard', 'report', 'role', 'permission', + ] as const; + + type Covered = (typeof COVERED)[number]; + + it('is exactly the {type} segment of every member — no more, no less', () => { + // Two directions, because one is not exactness. The `extends Covered` + // direction alone would pass VACUOUSLY if the derivation ever collapsed to + // `never` (`never extends X` is true for any X) — the phantom this pin + // would otherwise be. The `Covered extends …` direction is what catches + // that collapse, and also catches a member silently dropped from the enum. + type NoStrangers = MetadataEventSubject extends Covered ? 'exact' : never; + type NoneMissing = Covered extends MetadataEventSubject ? 'exact' : never; + const both: [NoStrangers, NoneMissing] = ['exact', 'exact']; + expect(both).toEqual(['exact', 'exact']); + }); + + it('recomposes to MetadataEventType exactly, so the 13 x 3 grid stays full', () => { + // The type-level twin of the runtime "created/updated/deleted triple" + // assertion above, and the reason a partial extension cannot land quietly: + // adding only `metadata.translation.created` makes this recomposition + // produce `metadata.translation.updated` — a name the enum does not have — + // and the first line below resolves to `never`. + type Recomposed = `metadata.${MetadataEventSubject}.${'created' | 'updated' | 'deleted'}`; + type NoStrangers = Recomposed extends MetadataEventType ? 'exact' : never; + type NoneMissing = MetadataEventType extends Recomposed ? 'exact' : never; + const both: [NoStrangers, NoneMissing] = ['exact', 'exact']; + expect(both).toEqual(['exact', 'exact']); + }); + + it('does not admit registrable metadata types that have no event contract', () => { + // These are all real `DEFAULT_METADATA_TYPE_REGISTRY` types. #4602 pinned + // that the producer publishes NOTHING for them; this pins that a consumer + // cannot claim to subscribe to them either. Wrapped in tuples so the check + // is "this literal is not in the union", not a distributed one. + type Rejects = [T] extends [MetadataEventSubject] ? never : 'rejected'; + const offContract: [ + Rejects<'translation'>, + Rejects<'datasource'>, + Rejects<'page'>, + Rejects<'hook'>, + Rejects<'trigger'>, + Rejects<'validation'>, + ] = ['rejected', 'rejected', 'rejected', 'rejected', 'rejected', 'rejected']; + expect(offContract).toHaveLength(6); + + // Widening the enum to cover them is axis 2 of #4627 — a product question + // about which types deserve a realtime event, deliberately left open. When + // one is answered (e.g. #4426 putting `translation` in play), the enum + // gains its three names, this list loses an entry, and both sides of the + // wire move together because neither side restates the vocabulary. + }); + + it('is derived from the enum, not restated beside it', () => { + // The runtime half of the pin: COVERED above is hand-written, so this is + // what stops the type-level assertions from certifying a stale list. A + // member added to `MetadataEventType` fails HERE first, by name. + const subjects = [...new Set(MetadataEventType.options.map((v) => v.split('.')[1]))]; + expect(subjects).toEqual([...COVERED]); + }); +}); + describe('MetadataEventSchema', () => { const base = { id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a1', diff --git a/packages/spec/src/api/events.zod.ts b/packages/spec/src/api/events.zod.ts index a35b2cdb6b..c5016c0bbd 100644 --- a/packages/spec/src/api/events.zod.ts +++ b/packages/spec/src/api/events.zod.ts @@ -58,6 +58,62 @@ export const MetadataEventType = z.enum([ export type MetadataEventType = z.infer; +/** + * The `{action}` half of `metadata.{type}.{action}`. + * + * Module-private: it exists to make {@link MetadataEventSubject}'s pattern + * spell the real suffix instead of `${string}`, which would also match a type + * name containing a dot and silently mis-split it. Not exported — the actions + * are a property of the event-name grammar, not a vocabulary anything outside + * this file chooses from. + */ +type MetadataEventAction = 'created' | 'updated' | 'deleted'; + +/** + * Splits one `metadata.{type}.{action}` name into its `{type}` segment. + * + * The type parameter is what makes this work: a conditional type distributes + * over a union only through a NAKED type parameter, so matching the whole + * {@link MetadataEventType} union against the pattern inline would infer one + * answer for all 39 members instead of 13 answers. Module-private for the same + * reason {@link MetadataEventAction} is — it is derivation machinery, not a + * contract anyone authors against. + */ +type MetadataEventSubjectOf = + T extends `metadata.${infer Subject}.${MetadataEventAction}` ? Subject : never; + +/** + * Metadata Event Subject — the `{type}` half of `metadata.{type}.{action}`: + * `'object' | 'field' | 'view' | …`, DERIVED from {@link MetadataEventType} + * rather than restated, so the two can never disagree. + * + * This is the set of metadata types that HAVE a realtime event contract, and + * it is the same set on both sides of the wire: + * + * - **Publish** (#4602, `MetadataManager.register`/`unregister`): a metadata + * type outside {@link MetadataEventType} publishes nothing — there is no + * event shape that could be delivered to a `(event: MetadataEvent) => void` + * callback, and emitting one every compliant consumer must reject is worse + * than silence. + * - **Subscribe** (#4627, `@objectstack/client`'s `subscribeMetadata` and the + * `@objectstack/client-react` hooks that delegate to it): the parameter is + * THIS type, so subscribing to a type that will never fire — + * `subscribeMetadata('translation', …)` — is a compile error rather than a + * green build with a permanently silent callback. + * + * `MetadataManager.register()` accepts any `type` string and + * `DEFAULT_METADATA_TYPE_REGISTRY` holds more types than this + * (`translation`, `datasource`, `page`, `hook`, `trigger`, `validation`, …). + * That gap is deliberate and is NOT closed by widening this alias: which + * additional types deserve a realtime event is a product question, tracked as + * axis 2 of #4627 and answered only when a real consumer needs one (e.g. + * #4426 would put `translation` in play). Until then the honest statement is + * the narrow one — adding a member here without adding its three + * {@link MetadataEventType} names would re-open exactly the silent-subscription + * hole this alias closes, and the derivation makes that impossible to express. + */ +export type MetadataEventSubject = MetadataEventSubjectOf; + /** * Data Event Types *