Skip to content
Merged
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
56 changes: 56 additions & 0 deletions .changeset/subscribe-metadata-event-subject.md
Original file line number Diff line number Diff line change
@@ -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` 推上来)。枚举没有动一个成员。派生关系保证了这件事将来只需要改一处:枚举加三个名字,两端同时跟上。
16 changes: 14 additions & 2 deletions packages/client-react/src/realtime-hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' },
},
}
);

Expand Down
21 changes: 16 additions & 5 deletions packages/client-react/src/realtime-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -36,7 +46,7 @@ import { useEventCallback } from './internal-deps';
* ```
*/
export function useMetadataSubscription(
type: string,
type: MetadataEventSubject,
options?: { packageId?: string }
): MetadataEvent | null {
const client = useClient();
Expand Down Expand Up @@ -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
*
Expand All @@ -130,7 +141,7 @@ export function useDataSubscription(
* ```
*/
export function useMetadataSubscriptionCallback(
type: string,
type: MetadataEventSubject,
callback: (event: MetadataEvent) => void,
options?: { packageId?: string }
): void {
Expand Down
96 changes: 96 additions & 0 deletions packages/client/src/realtime-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<RealtimeAPI['subscribeMetadata']>[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();
});
});
37 changes: 29 additions & 8 deletions packages/client/src/realtime-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
DataEventSchema,
BulkDataEventSchema,
type MetadataEvent,
type MetadataEventSubject,
type MetadataEventType,
type DataEvent,
type BulkDataEvent,
} from '@objectstack/spec/api';
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@
"MetadataEndpointsConfigSchema (const)",
"MetadataEvent (type)",
"MetadataEventSchema (const)",
"MetadataEventSubject (type)",
"MetadataEventType (type)",
"MetadataExistsResponse (type)",
"MetadataExistsResponseSchema (const)",
Expand Down
Loading
Loading