Skip to content

Commit d52d4fe

Browse files
os-zhuangclaude
andauthored
feat(spec)!: 移除 ./ui 的 HttpMethod 导出 —— C14 双源清账,5 值子集用既有名 HttpMethodType (#4691) (#4727)
`HttpMethod` 曾由三个入口导出但指向两个不同声明:`./shared`/`./api` 是 7 值 z.enum(含 HEAD/OPTIONS),`./ui` 是 `z.infer<typeof HttpMethodSchema>`(5 值 UI 子集)。同名异型,拿到哪个取决于 import 路径(#4411 陷阱),且两侧取值 集合不同,不能像 C11(#4688)那样用 re-export 收敛 —— 那会把 `./ui` 的类型 放宽到 7 值,而 `HttpRequestSchema.method` 运行时仍只接受 5 值,类型开始对 运行时说谎。 处置:删除 `./ui` 的 `HttpMethod` 导出(名字只剩 `./shared`+`./api` 的 7 值 那一个声明),并从 `./ui` re-export 既有的 5 值类型名 `HttpMethodType` (shared/http.zod.ts 的同一声明,零形状变化),迁移是同入口内的一次改名。 `./shared` 的 7 值声明与 `HttpMethodSchema` 的 5 值语义一字未动。 - dual-source-exports.baseline.json:删 `HttpMethod` 行(14 → 13, #4535 v17 双源账最后一条) - api-surface.json:`./ui` 一行对换(- HttpMethod (type) / + HttpMethodType (type)) - docs-import-surface.baseline.json +1 行、references/ui/http.mdx:生成器产出 - pin(view.test.ts [#4691]):compiler-API 断言 `./ui` 无 `HttpMethod`、 值域 7/5 双向 pin、`HttpRequestSchema.parse({method:'HEAD'})` 拒绝 pin; 全部 sabotage 实证 - changeset:@objectstack/spec major(移除已发布导出名),含 FROM → TO 已知外部消费者:objectui packages/types(type-only 一行),迁移单 objectui#3235;objectstack / cloud 两仓零消费者(实测)。 Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 203a449 commit d52d4fe

7 files changed

Lines changed: 214 additions & 4 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
BREAKING(spec): `@objectstack/spec/ui` 不再导出 `HttpMethod` —— 该名字在本包内指向**两个不同的类型**,`./ui` 那一个改名为 `HttpMethodType` (#4691, #4535 C14)
6+
7+
`HttpMethod` 过去被三个入口导出,但**不是同一个声明**,拿到哪个只取决于 import 路径 —— #4411 陷阱。而且与 C11(`HttpRequest`)不同,这一簇两侧连**取值集合都不一样**:
8+
9+
| 入口 | 声明位置 | 取值 |
10+
|:--|:--|:--|
11+
| `@objectstack/spec/shared`、`@objectstack/spec/api`(**不变**) | `shared/http.zod.ts` 的 `z.enum([...])` | **7 值** — `GET` `POST` `PUT` `DELETE` `PATCH` **`HEAD` `OPTIONS`** |
12+
| `@objectstack/spec/ui`(**本次移除**) | `ui/view.zod.ts` 的 `z.infer< typeof HttpMethodSchema >` | **5 值** — `GET` `POST` `PUT` `PATCH` `DELETE` |
13+
14+
7 值那个描述的是「HTTP 协议本身有哪些方法」(CORS `methods[]`、REST 路由表、endpoint 声明都用它);5 值那个是 UI/View 数据源允许配置的**真子集**,它的注释自己写着 *"HTTP Method Schema (subset for UI/View data sources)"*。
15+
16+
## FROM → TO
17+
18+
```ts
19+
// FROM —— 拿到的是 5 值的 UI 子集类型
20+
import type { HttpMethod } from '@objectstack/spec/ui';
21+
22+
// TO —— 同一个类型,同一个入口,零形状变化
23+
import type { HttpMethodType } from '@objectstack/spec/ui';
24+
```
25+
26+
`HttpMethodType` 是 `shared/http.zod.ts` 里 `z.infer< typeof HttpMethodSchema >` 的既有名字(`@objectstack/spec/shared` 一直在导出),本次由 `./ui` **re-export** 同一个声明,所以改完之后解析到的类型与改之前逐字相同。
27+
28+
⚠️ **不要把 import 路径改成 `@objectstack/spec/shared` 而保留 `HttpMethod` 这个名字。** 那里的 `HttpMethod` 是**7 值**的那一个,会把类型悄悄放宽两个值,而 `HttpRequestSchema.method` 运行时只接受 5 值 —— `method: 'HEAD'` 会通过编译、在 `.parse()` 抛错。之所以把 `HttpMethodType` 也从 `./ui` re-export 出去,就是为了让编译器的 "did you mean" 指向同一入口里正确的那个名字,而不是引诱这次换路径。
29+
30+
用 7 值枚举的代码不受影响:`import { HttpMethod } from '@objectstack/spec/api'`(或 `/shared`)行为一字未变。
31+
32+
## 为什么不是「让 `./ui` re-export `./shared` 的 `HttpMethod`」
33+
34+
C11(#4688)对 `HttpRequest` 用的正是这一招,当时是对的 —— 两侧 `z.infer` 的是**同一个 schema 对象**,形状逐字段相同,收敛后消费者零感知。
35+
36+
本簇不成立:那样做会把 `./ui` 的 `HttpMethod` 从 5 值放宽到 7 值,而 `HttpRequestSchema.method`(`shared/http.zod.ts`)校验用的仍是 5 值的 `HttpMethodSchema`。结果是**类型开始对运行时说谎** —— 少一行基线,换来一个编译期放行、运行期抛错的坑。所以走的是「`./ui` 不再叫这个名字」,让 5 值类型保留它在 `./shared` 里已有的诚实名字。
37+
38+
`HttpMethodSchema` 的值域**一字未动**(仍是 5 值),`HttpRequestSchema` 的运行时行为**零变化**。
39+
40+
## 定级理由(逐条自证,未照抄前例)
41+
42+
定 **major**,因为这是一次**已发布导出名的移除**:外部 `import type { HttpMethod } from '@objectstack/spec/ui'` 会以 TS2305 编译失败。这与 C11 定 patch 的情形正相反 —— 那次名字仍在导出、只是换了声明来源,消费者无需改一个字符;本次名字没了。
43+
44+
同时它**不是元数据破坏**:
45+
- `authorable-surface.json` **零变化**(实跑 `check:authorable-surface` ✓)—— `HttpMethod` 是纯 TS 类型别名,不是可作者化的 key。
46+
- 因此**无 tombstone、无 ADR-0087 conversion / migration**,`spec-changes.json` 与 `protocol-upgrade-guide.md` 零变化(两个 gate 均 ✓)。
47+
- 已存 `sys_metadata` 数据、JSON Schema 产物、运行时校验行为全部不受影响。
48+
49+
也就是说:**零元数据迁移,只有一处一行的 TypeScript import 改动。**
50+
51+
## 基线 14 → 13
52+
53+
`dual-source-exports.baseline.json` 删掉 `HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]` 一行,其余 13 行一字未动。这是 #4535 v17 双源账的**最后一条**。
54+
55+
`api-surface.json` 的改动恰好只有 `./ui` 的一行对换:`- HttpMethod (type)` / `+ HttpMethodType (type)`。

‎content/docs/references/ui/http.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ description: Http protocol schemas
99

1010
```typescript
1111
import { HttpMethodSchema, HttpRequestSchema } from '@objectstack/spec/ui';
12-
import type { HttpMethod, HttpRequest } from '@objectstack/spec/ui';
12+
import type { HttpRequest } from '@objectstack/spec/ui';
1313

1414
// Validate data
1515
const result = HttpMethodSchema.parse(data);

‎packages/spec/api-surface.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3394,8 +3394,8 @@
33943394
"GroupingConfig (type)",
33953395
"GroupingConfigSchema (const)",
33963396
"GroupingFieldSchema (const)",
3397-
"HttpMethod (type)",
33983397
"HttpMethodSchema (const)",
3398+
"HttpMethodType (type)",
33993399
"HttpRequest (type)",
34003400
"HttpRequestSchema (const)",
34013401
"I18nLabel (type)",

‎packages/spec/docs-import-surface.baseline.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@
123123
"ui/GanttConfig — no type export",
124124
"ui/GanttQuickFilter — no type export",
125125
"ui/GroupingField — no type export",
126+
"ui/HttpMethod — no type export",
126127
"ui/KanbanConfig — no type export",
127128
"ui/NavigationMode — no type export",
128129
"ui/ObjectListView — no type export",

‎packages/spec/dual-source-exports.baseline.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
"EnvironmentArtifactInput — [./cloud (type)] ≠ [./system (type)]",
1111
"EnvironmentArtifactSchema — [./cloud (const)] ≠ [./system (const)]",
1212
"EventSchema — [./automation (const)] ≠ [./kernel (const)]",
13-
"HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]",
1413
"PackageDependency — [./cloud (type)] ≠ [./kernel (type)]",
1514
"PackageDependencySchema — [./cloud (const)] ≠ [./kernel (const)]",
1615
"TenantPlan — [./cloud (type)] ≠ [./system (type)]",

‎packages/spec/src/ui/view.test.ts‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2736,6 +2736,126 @@ describe('[#4688] HttpRequest is single-source across ./shared and ./ui', () =>
27362736
});
27372737
});
27382738

2739+
// ─── [#4691] `HttpMethod` is gone from ./ui — the LAST dual-source row ───────
2740+
//
2741+
// The sibling of the #4688 pin above, and deliberately NOT the same fix. There,
2742+
// `./shared` and `./ui` named two declarations of the *same* shape and the cure
2743+
// was a re-export. Here the two declarations are genuinely different types:
2744+
//
2745+
// shared/http.zod.ts `export const/type HttpMethod` → 7 values (+HEAD/OPTIONS)
2746+
// shared/http.zod.ts `HttpMethodSchema`/`HttpMethodType` → 5 values (UI subset)
2747+
// ui/view.zod.ts `export type HttpMethod` (removed) → the 5-value one
2748+
//
2749+
// So re-exporting `./shared`'s into `./ui` would have widened the UI type to 7
2750+
// while `HttpRequestSchema.method` still accepts only 5 — a type that lies about
2751+
// its own runtime. The name was removed from `./ui` instead.
2752+
//
2753+
// Same reasoning as #4688 on the mechanism: `HttpMethod` is a TYPE, erased
2754+
// before any runtime assertion can see it, and #4642 established that a
2755+
// compile-time pin in this package is a no-op (`tsconfig.json` excludes
2756+
// `**/*.test.ts`; vitest never enables `typecheck`). The compiler-API test is
2757+
// therefore the load-bearing one; the runtime tests below it guard the value
2758+
// ranges the whole argument rests on.
2759+
describe('[#4691] `HttpMethod` is not exported from ./ui', () => {
2760+
it('resolves the export surface: ./ui has no `HttpMethod`, ./shared and ./api share one', async () => {
2761+
const ts = (await import('typescript')).default;
2762+
const { resolve, relative, dirname } = await import('node:path');
2763+
const { fileURLToPath } = await import('node:url');
2764+
2765+
const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
2766+
const entries = {
2767+
'./shared': resolve(specDir, 'src/shared/index.ts'),
2768+
'./ui': resolve(specDir, 'src/ui/index.ts'),
2769+
'./api': resolve(specDir, 'src/api/index.ts'),
2770+
};
2771+
const program = ts.createProgram(Object.values(entries), {
2772+
module: ts.ModuleKind.ESNext,
2773+
moduleResolution: ts.ModuleResolutionKind.Bundler,
2774+
skipLibCheck: true,
2775+
noEmit: true,
2776+
});
2777+
const checker = program.getTypeChecker();
2778+
const unalias = (s: import('typescript').Symbol) =>
2779+
s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s;
2780+
2781+
const exportsOf = (sub: keyof typeof entries) => {
2782+
const sf = program.getSourceFile(entries[sub]);
2783+
const moduleSym = sf && checker.getSymbolAtLocation(sf);
2784+
// Without this guard a resolution failure would make every assertion
2785+
// below pass vacuously — the exact way a gate goes dormant (#4642).
2786+
expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy();
2787+
return checker.getExportsOfModule(moduleSym!);
2788+
};
2789+
2790+
// A sanity anchor: if this entry resolved to nothing, `find` returning
2791+
// undefined for `HttpMethod` below would prove nothing at all.
2792+
const uiExports = exportsOf('./ui');
2793+
expect(uiExports.length, './ui must export a non-trivial surface').toBeGreaterThan(50);
2794+
2795+
// 1. The row this change removes: `./ui` no longer names `HttpMethod`.
2796+
expect(uiExports.map((e) => e.getName())).not.toContain('HttpMethod');
2797+
2798+
// 2. …but it still offers the 5-value type under its honest name, so the
2799+
// migration stays inside this entry point.
2800+
const uiMethodType = uiExports.find((e) => e.getName() === 'HttpMethodType');
2801+
expect(uiMethodType, './ui must export `HttpMethodType`').toBeTruthy();
2802+
2803+
const originOf = (sym: import('typescript').Symbol, label: string) => {
2804+
const decl = unalias(sym).declarations?.[0];
2805+
expect(decl, `${label} must have a declaration`).toBeTruthy();
2806+
const declFile = decl!.getSourceFile();
2807+
return `${relative(specDir, declFile.fileName)}:${
2808+
declFile.getLineAndCharacterOfPosition(decl!.getStart()).line + 1
2809+
}`;
2810+
};
2811+
2812+
expect(originOf(uiMethodType!, './ui HttpMethodType'))
2813+
.toMatch(/^src\/shared\/http\.zod\.ts:\d+$/);
2814+
2815+
// 3. `./shared` and `./api` keep naming ONE declaration `HttpMethod` — the
2816+
// 7-value one. This change must not have disturbed that side.
2817+
const origins = new Map<string, string>();
2818+
for (const sub of ['./shared', './api'] as const) {
2819+
const exported = exportsOf(sub).find((e) => e.getName() === 'HttpMethod');
2820+
expect(exported, `${sub} must still export \`HttpMethod\``).toBeTruthy();
2821+
origins.set(sub, originOf(exported!, `${sub} HttpMethod`));
2822+
}
2823+
expect(origins.get('./api')).toBe(origins.get('./shared'));
2824+
expect(origins.get('./shared')).toMatch(/^src\/shared\/http\.zod\.ts:\d+$/);
2825+
});
2826+
2827+
it('keeps the two value ranges distinct: 7 for `HttpMethod`, 5 for `HttpMethodSchema`', async () => {
2828+
const sharedEntry = await import('../shared/index');
2829+
const apiEntry = await import('../api/index');
2830+
2831+
// `./api` re-exports the const, so this is one object seen twice.
2832+
expect(apiEntry.HttpMethod).toBe(sharedEntry.HttpMethod);
2833+
2834+
expect([...sharedEntry.HttpMethod.options].sort()).toEqual(
2835+
['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'],
2836+
);
2837+
expect([...sharedEntry.HttpMethodSchema.options].sort()).toEqual(
2838+
['DELETE', 'GET', 'PATCH', 'POST', 'PUT'],
2839+
);
2840+
2841+
// The subset relation is the whole reason the two names cannot merge.
2842+
expect(sharedEntry.HttpMethod.options).toContain('HEAD');
2843+
expect(sharedEntry.HttpMethodSchema.options).not.toContain('HEAD');
2844+
});
2845+
2846+
it('rejects `HEAD` at the parse layer — the runtime the ./ui type must not out-promise', async () => {
2847+
const uiEntry = await import('../ui/index');
2848+
2849+
expect(() => uiEntry.HttpMethodSchema.parse('HEAD')).toThrow();
2850+
expect(() => uiEntry.HttpMethodSchema.parse('OPTIONS')).toThrow();
2851+
expect(() => uiEntry.HttpRequestSchema.parse({ url: '/api/data', method: 'HEAD' })).toThrow();
2852+
// …while the 5 it does accept still round-trip, so the guard above is not
2853+
// passing because the schema rejects everything.
2854+
expect(uiEntry.HttpRequestSchema.parse({ url: '/api/data', method: 'PATCH' }).method)
2855+
.toBe('PATCH');
2856+
});
2857+
});
2858+
27392859
describe('ADR-0089 — visibleWhen unification (view form)', () => {
27402860
it('normalizes a deprecated `visibleOn` alias to `visibleWhen` on a form field', () => {
27412861
const parsed = FormFieldSchema.parse({ field: 'state', visibleOn: "record.country == 'US'" });

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,34 @@ export { HttpMethodSchema, HttpRequestSchema };
5353
*/
5454
export type { HttpRequest } from '../shared/http.zod';
5555

56+
/**
57+
* [#4691] The type of `HttpMethodSchema` is `HttpMethodType`, RE-EXPORTED from
58+
* its one declaration in `shared/http.zod.ts`.
59+
*
60+
* `./ui` used to export that same 5-value type under the name `HttpMethod`
61+
* (`export type HttpMethod = z.infer< typeof HttpMethodSchema >`, in the alias
62+
* block at the bottom of this file). That name is already taken across the
63+
* package by a DIFFERENT declaration — `shared/http.zod.ts`'s 7-value
64+
* `z.enum([… 'HEAD', 'OPTIONS'])`, exported by `./shared` and `./api` — so one
65+
* name resolved to two incompatible types depending on the import path (the
66+
* #4411 trap; the last row of `dual-source-exports.baseline.json`).
67+
*
68+
* Converging by re-exporting `./shared`'s `HttpMethod` here — the fix #4688
69+
* used for `HttpRequest` — would have been WRONG: it silently widens `./ui`'s
70+
* type from 5 values to 7 while `HttpRequestSchema.method` still validates
71+
* against the 5-value `HttpMethodSchema`. `method: 'HEAD'` would type-check and
72+
* then throw at `.parse()` — the type would start lying about the runtime. So
73+
* the NAME is dropped from `./ui` instead, and the honest 5-value type keeps
74+
* the name it already carries in `./shared`.
75+
*
76+
* Re-exported here (rather than only left in `./shared`) so the shortest fix
77+
* for `import type { HttpMethod } from '@objectstack/spec/ui'` is also the
78+
* CORRECT one: TypeScript's "did you mean" points at `HttpMethodType` in the
79+
* same entry point, instead of tempting a path swap to `./shared`, where the
80+
* name `HttpMethod` does still exist and means the wider 7-value enum.
81+
*/
82+
export type { HttpMethodType } from '../shared/http.zod';
83+
5684
/**
5785
* View Data Source Configuration
5886
* Supports three modes:
@@ -2058,7 +2086,14 @@ export type ViewData = z.infer<typeof ViewDataSchema>;
20582086
// this file (#4688). Re-adding `= z.infer<typeof HttpRequestSchema>` below would
20592087
// re-create the dual-source row this change removed, even though the inferred
20602088
// shape is identical.
2061-
export type HttpMethod = z.infer<typeof HttpMethodSchema>;
2089+
//
2090+
// `HttpMethod` is not declared here either — and is no longer exported from
2091+
// `./ui` at all (#4691). It used to be `= z.infer<typeof HttpMethodSchema>`,
2092+
// the 5-value UI subset, while `./shared` and `./api` export a DIFFERENT,
2093+
// 7-value declaration under that same name. Re-exporting theirs would widen
2094+
// this entry's type past what `HttpRequestSchema.method` actually validates, so
2095+
// the name was dropped instead; the 5-value type is re-exported as
2096+
// `HttpMethodType` at the top of this file, where the full rationale lives.
20622097
export type ColumnSummary = z.infer<typeof ColumnSummarySchema>;
20632098
export type ColumnSummaryConfig = z.infer<typeof ColumnSummaryConfigSchema>;
20642099
export type ColumnPrefix = z.infer<typeof ColumnPrefixSchema>;

0 commit comments

Comments
 (0)