Skip to content

Commit def5919

Browse files
qq9340100claude
andauthored
refactor(client)!: subscribeMetadata 的 type 收窄为 MetadataEventSubject (#4627) (#6156)
#4602 已把生产端钉成 declared = enforced —— MetadataEventType 枚举外的 metadata 类型不发布任何 realtime 事件。消费端却仍是宽的 string,于是 subscribeMetadata('translation', cb) 编译全绿、运行永盲。 本次把消费端也钉上:新增 spec 派生类型 MetadataEventSubject(从 MetadataEventType 用模板字面量 + 分发式条件类型解出 {type} 半边,不是 重抄一份),并收窄三处签名 —— client 的 subscribeMetadata、client-react 的 useMetadataSubscription / useMetadataSubscriptionCallback。 轴 2(扩枚举覆盖面)不预答,枚举一个成员都没动。 Refs #4627 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY Co-authored-by: Claude <noreply@anthropic.com>
1 parent bdc8e70 commit def5919

8 files changed

Lines changed: 353 additions & 15 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/client": major
4+
"@objectstack/client-react": major
5+
---
6+
7+
refactor(client)!: `subscribeMetadata``type` 收窄为 `MetadataEventSubject`,订阅一个合同上永远不会来的事件改为编译报错 (#4627)
8+
9+
`MetadataEventType` 是一个封闭枚举:13 个 metadata 类型 × 3 个动作。#4602 已经把生产端钉成 declared = enforced —— 枚举外的类型(`translation``datasource``page``hook``trigger``validation` 等,全都是 `DEFAULT_METADATA_TYPE_REGISTRY` 里可注册的真实类型)**不发布**任何 realtime 事件,因为不存在能合法交付给 `(event: MetadataEvent) => void` 回调的事件形状。
10+
11+
消费端却一直是宽的 `string`。于是 `client.events.subscribeMetadata('translation', cb)` 编译全绿、运行永盲:回调永远不会被调用,而类型系统一个字都没说。这正是 AI 写订阅代码最容易踩的形状 —— 它看起来订阅上了。
12+
13+
本次把消费端也钉上,两端对齐后这种代码写不出来。
14+
15+
**新增导出**`@objectstack/spec/api``MetadataEventSubject` —— `metadata.{type}.{action}``{type}` 半边,`'object' | 'field' | 'view' | …`。它是从 `MetadataEventType` **派生**的(模板字面量 + 分发式条件类型),不是在旁边重抄一份,所以两者不可能各说各话:枚举加一个成员,这个联合自动跟着长。`check:api-surface` 记录为 0 breaking / 1 added。
16+
17+
**签名收窄**(三处,全部只是把 `string` 换成这个联合):
18+
19+
- `@objectstack/client``RealtimeAPI.subscribeMetadata(type, …)`
20+
- `@objectstack/client-react``useMetadataSubscription(type, …)`
21+
- `@objectstack/client-react``useMetadataSubscriptionCallback(type, …)`
22+
23+
**FROM → TO —— 原来传 `string` 的代码怎么改**
24+
25+
枚举内的字面量一个字都不用动,本仓 6 处调用点(`'object'`)零迁移:
26+
27+
```ts
28+
// 照常编译,没有变化
29+
client.events.subscribeMetadata('object', onEvent);
30+
useMetadataSubscription('view');
31+
```
32+
33+
真正被拒绝的只有两种写法,各有各的一行修复:
34+
35+
```ts
36+
// FROM —— 变量声明成了宽的 string
37+
const type: string = route.params.metaType;
38+
client.events.subscribeMetadata(type, onEvent); // TS2345
39+
40+
// TO —— 把变量(或 state、或路由参数)的类型改成这个联合
41+
import type { MetadataEventSubject } from '@objectstack/spec/api';
42+
const type: MetadataEventSubject = 'object';
43+
client.events.subscribeMetadata(type, onEvent);
44+
```
45+
46+
```ts
47+
// FROM —— 订阅一个没有 realtime 合同的类型
48+
client.events.subscribeMetadata('translation', onEvent); // TS2345
49+
50+
// TO —— 删掉它。这段代码从 #4602 起就收不到任何事件,
51+
// 编译器现在说的是它一直以来的运行时事实,不是新增的限制。
52+
```
53+
54+
编译器会把每一处指出来,错误码都是 **TS2345**`Argument of type '"translation"' is not assignable to parameter of type 'MetadataEventSubject'`)。**运行时行为零变化** —— 被拒绝的调用本来就收不到事件,标 major 是因为这是源码级破坏性变更(#5181 的同一条先例:源码级破坏、运行时不变,仍走 major)。
55+
56+
**本次不做、也不预答的**:哪些可注册类型「应该」有 realtime 事件,是 #4627 的轴 2 —— 一个由真实需求驱动的产品覆盖面问题(例如 #4426 的 flow/workflow i18n 若落地会把 `translation` 推上来)。枚举没有动一个成员。派生关系保证了这件事将来只需要改一处:枚举加三个名字,两端同时跟上。

packages/client-react/src/realtime-hooks.test.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ import * as React from 'react';
3232
import { describe, it, expect, vi } from 'vitest';
3333
import { renderHook, act } from '@testing-library/react';
3434
import type { ObjectStackClient } from '@objectstack/client';
35-
import type { BulkDataEvent, DataEvent, MetadataEvent } from '@objectstack/spec/api';
35+
import type {
36+
BulkDataEvent,
37+
DataEvent,
38+
MetadataEvent,
39+
MetadataEventSubject,
40+
} from '@objectstack/spec/api';
3641
import { ObjectStackProvider } from './context';
3742
import {
3843
useAutoRefresh,
@@ -209,7 +214,14 @@ describe('#4682 dependency arrays drive re-subscription', () => {
209214
({ type, options }) => useMetadataSubscription(type, options),
210215
{
211216
wrapper: wrapperFor(client),
212-
initialProps: { type: 'object', options: { packageId: 'crm' } },
217+
// Annotated, not widened: `renderHook` infers the prop type from this
218+
// literal, and a bare `'object'` widens to `string` — which the hook
219+
// no longer accepts (#4627). `rerender({ type: 'view' })` below is
220+
// exactly why the annotation must be the union rather than the literal.
221+
initialProps: {
222+
type: 'object' as MetadataEventSubject,
223+
options: { packageId: 'crm' },
224+
},
213225
}
214226
);
215227

packages/client-react/src/realtime-hooks.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,24 @@
88
*/
99

1010
import { useEffect, useState } from 'react';
11-
import type { MetadataEvent, DataEvent, BulkDataEvent } from '@objectstack/spec/api';
11+
import type {
12+
MetadataEvent,
13+
MetadataEventSubject,
14+
DataEvent,
15+
BulkDataEvent,
16+
} from '@objectstack/spec/api';
1217
import { useClient } from './context';
1318
import { useEventCallback } from './internal-deps';
1419

1520
/**
1621
* Hook to subscribe to metadata events
1722
*
18-
* @param type - Metadata type to subscribe to (e.g., 'object', 'view', 'agent')
23+
* @param type - Metadata type to subscribe to (e.g., 'object', 'view', 'agent').
24+
* Typed {@link MetadataEventSubject}, the closed set derived from
25+
* `MetadataEventType` (#4627) — a metadata type with no realtime event
26+
* contract (`'translation'`, `'datasource'`, …) is a compile error here
27+
* rather than a subscription that never fires. This hook only forwards the
28+
* argument to `subscribeMetadata`, so it must not be the looser of the two.
1929
* @param options - Optional filters (packageId)
2030
* @returns Latest metadata event or null
2131
*
@@ -36,7 +46,7 @@ import { useEventCallback } from './internal-deps';
3646
* ```
3747
*/
3848
export function useMetadataSubscription(
39-
type: string,
49+
type: MetadataEventSubject,
4050
options?: { packageId?: string }
4151
): MetadataEvent | null {
4252
const client = useClient();
@@ -112,7 +122,8 @@ export function useDataSubscription(
112122
* This variant doesn't store events in state, it just triggers a callback.
113123
* Useful for triggering refetches or side effects without re-renders.
114124
*
115-
* @param type - Metadata type to subscribe to
125+
* @param type - Metadata type to subscribe to. Same {@link MetadataEventSubject}
126+
* narrowing as {@link useMetadataSubscription} (#4627).
116127
* @param callback - Callback to invoke on events
117128
* @param options - Optional filters
118129
*
@@ -130,7 +141,7 @@ export function useDataSubscription(
130141
* ```
131142
*/
132143
export function useMetadataSubscriptionCallback(
133-
type: string,
144+
type: MetadataEventSubject,
134145
callback: (event: MetadataEvent) => void,
135146
options?: { packageId?: string }
136147
): void {

packages/client/src/realtime-api.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2323
import type { RealtimeEventPayload } from '@objectstack/spec/contracts';
24+
import type { MetadataEvent, MetadataEventSubject } from '@objectstack/spec/api';
2425
import { RealtimeAPI } from './realtime-api';
2526

2627
const VALID_EVENT = {
@@ -138,3 +139,98 @@ describe('#4602 — RealtimeAPI.subscribeMetadata contract boundary', () => {
138139
expect(callback).toHaveBeenCalledTimes(1);
139140
});
140141
});
142+
143+
// ===========================================================================
144+
// #4627 — the `type` parameter is the closed MetadataEventSubject, not string
145+
// ===========================================================================
146+
//
147+
// Every pin below is resolved by tsc, not by vitest. `packages/client`'s
148+
// `typecheck` script compiles this file through `tsconfig.test.json`, and
149+
// `test-typecheck-debt.json` carries NO entry for `src/realtime-api.test.ts` —
150+
// which, under that ledger's "a file not listed here may have no errors at all"
151+
// rule, makes zero the measurable baseline these pins move away from. Reverting
152+
// the parameter to `string` leaves the `@ts-expect-error` directives unused,
153+
// and an unused directive is itself an error (TS2578), so the revert is red.
154+
//
155+
// Reverse verification, direction declared before running it: `type: string`
156+
// restored → the two `@ts-expect-error` lines below go red as TS2578 "Unused
157+
// '@ts-expect-error' directive", and `ParameterIsExactlySubject` resolves to
158+
// `never` so its initializer goes red as TS2322. Both were measured and both
159+
// landed. A fourth red was NOT predicted and is recorded here rather than
160+
// tidied away: `realtime-api.ts`'s own `const eventTypes: MetadataEventType[]`
161+
// goes red too (three TS2322, `` `metadata.${string}.created` `` not assignable
162+
// to the enum), because a widened `type` makes the composed names unprovable.
163+
// The implementation carries a pin of its own, one level below the signature.
164+
//
165+
// The `@ts-expect-error` directives are deliberately NOT the only pin. A bare
166+
// directive passes on ANY error at that line — the phantom-check hazard — so
167+
// each is corroborated by a type-level assertion that fixes exactly WHICH
168+
// error it can be: the parameter type is pinned to `MetadataEventSubject`
169+
// exactly, and the positive cases below prove the callback/options arms of the
170+
// same signature still compile. What is left for the directive to catch can
171+
// then only be argument one (TS2345 — the code recorded in each comment,
172+
// measured by deleting the directive and reading tsc's output).
173+
174+
describe('#4627 — subscribeMetadata narrows `type` to the event vocabulary', () => {
175+
const api = new RealtimeAPI('http://localhost:3000');
176+
const callback = (_event: MetadataEvent): void => undefined;
177+
178+
it('declares the parameter as exactly MetadataEventSubject', () => {
179+
// Read off the METHOD, not off the alias: a revert that widened the
180+
// signature back to `string` while leaving `MetadataEventSubject` exported
181+
// would sail past any alias-scoped assertion.
182+
//
183+
// Both directions are asserted, and each catches a different regression:
184+
// - `Param extends MetadataEventSubject` fails on a re-widening
185+
// (`string extends MetadataEventSubject` is false);
186+
// - `MetadataEventSubject extends Param` fails on an over-narrowing to a
187+
// subset (`… extends 'object'` is false).
188+
// Together they are exactness; either alone is not.
189+
type Param = Parameters<RealtimeAPI['subscribeMetadata']>[0];
190+
type ParameterIsExactlySubject =
191+
Param extends MetadataEventSubject
192+
? MetadataEventSubject extends Param
193+
? 'exact'
194+
: never
195+
: never;
196+
const exact: ParameterIsExactlySubject = 'exact';
197+
expect(exact).toBe('exact');
198+
});
199+
200+
it('accepts every member of the vocabulary, not just the one this file uses', () => {
201+
// The other 12 subjects compile too — this is a union, not `'object'`.
202+
const offs = [
203+
api.subscribeMetadata('field', callback),
204+
api.subscribeMetadata('view', callback),
205+
api.subscribeMetadata('permission', callback, { packageId: 'com.acme' }),
206+
];
207+
expect(offs).toHaveLength(3);
208+
for (const off of offs) off();
209+
api.disconnect();
210+
});
211+
212+
it('rejects a metadata type that has no realtime event contract', () => {
213+
// `translation` IS registrable (`DEFAULT_METADATA_TYPE_REGISTRY`) but has
214+
// no `metadata.translation.*` name in `MetadataEventType`, so #4602's
215+
// producer publishes nothing for it. Before #4627 this line compiled and
216+
// the callback waited forever.
217+
// @ts-expect-error TS2345 - '"translation"' is not assignable to parameter of type 'MetadataEventSubject'
218+
const off = api.subscribeMetadata('translation', callback);
219+
expect(off).toBeTypeOf('function');
220+
off();
221+
api.disconnect();
222+
});
223+
224+
it('rejects a plain `string` variable — the one real migration break', () => {
225+
// This is what a 17.x caller has to change: a `string`-typed variable no
226+
// longer flows in. The fix is to type the variable (or the state, or the
227+
// route param) as `MetadataEventSubject`; there is no runtime change to
228+
// accompany it, because the runtime already ignored these subscriptions.
229+
const fromConfig: string = 'object';
230+
// @ts-expect-error TS2345 - 'string' is not assignable to parameter of type 'MetadataEventSubject'
231+
const off = api.subscribeMetadata(fromConfig, callback);
232+
expect(off).toBeTypeOf('function');
233+
off();
234+
api.disconnect();
235+
});
236+
});

packages/client/src/realtime-api.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
DataEventSchema,
1414
BulkDataEventSchema,
1515
type MetadataEvent,
16+
type MetadataEventSubject,
17+
type MetadataEventType,
1618
type DataEvent,
1719
type BulkDataEvent,
1820
} from '@objectstack/spec/api';
@@ -53,25 +55,44 @@ export class RealtimeAPI {
5355
}
5456

5557
/**
56-
* Subscribe to metadata events
57-
* Returns an unsubscribe function
58+
* Subscribe to metadata events for one metadata type.
59+
* Returns an unsubscribe function.
60+
*
61+
* `type` is {@link MetadataEventSubject} — the `{type}` half of the
62+
* `metadata.{type}.{action}` vocabulary, derived from `MetadataEventType` —
63+
* not a free string (#4627). The producer publishes nothing for a metadata
64+
* type outside that enum (#4602 pinned that as declared = enforced), so a
65+
* `string` parameter let a caller write `subscribeMetadata('translation', …)`,
66+
* compile green, and wait forever on a callback the contract guarantees will
67+
* never fire. Now the compiler says so at the call site.
68+
*
69+
* The narrowing does NOT decide which types deserve realtime events — that is
70+
* axis 2 of #4627 and stays open. It only stops the consumer from claiming a
71+
* coverage the producer never promised.
5872
*/
5973
subscribeMetadata(
60-
type: string,
74+
type: MetadataEventSubject,
6175
callback: (event: MetadataEvent) => void,
6276
options?: { packageId?: string }
6377
): () => void {
6478
const subscriptionId = `metadata-${type}-${Date.now()}`;
6579

80+
// Annotated `MetadataEventType[]`, not left to widen to `string[]`: with a
81+
// narrowed `type` these three templates are provably members of the enum,
82+
// and saying so makes tsc re-check the composition. A typo here
83+
// (`metadata.${type}.create`) used to be a silent no-match subscription —
84+
// the same defect class one level down from the one the parameter fixes.
85+
const eventTypes: MetadataEventType[] = [
86+
`metadata.${type}.created`,
87+
`metadata.${type}.updated`,
88+
`metadata.${type}.deleted`,
89+
];
90+
6691
this.subscriptions.set(subscriptionId, {
6792
filter: {
6893
type,
6994
packageId: options?.packageId,
70-
eventTypes: [
71-
`metadata.${type}.created`,
72-
`metadata.${type}.updated`,
73-
`metadata.${type}.deleted`
74-
]
95+
eventTypes
7596
},
7697
handler: (event) => {
7798
if (!event.type.startsWith('metadata.')) return;

packages/spec/api-surface/api.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,7 @@
526526
"MetadataEndpointsConfigSchema (const)",
527527
"MetadataEvent (type)",
528528
"MetadataEventSchema (const)",
529+
"MetadataEventSubject (type)",
529530
"MetadataEventType (type)",
530531
"MetadataExistsResponse (type)",
531532
"MetadataExistsResponseSchema (const)",

0 commit comments

Comments
 (0)