diff --git a/.changeset/http-method-dual-source-c14.md b/.changeset/http-method-dual-source-c14.md new file mode 100644 index 0000000000..e7bc20e3b9 --- /dev/null +++ b/.changeset/http-method-dual-source-c14.md @@ -0,0 +1,55 @@ +--- +"@objectstack/spec": major +--- + +BREAKING(spec): `@objectstack/spec/ui` 不再导出 `HttpMethod` —— 该名字在本包内指向**两个不同的类型**,`./ui` 那一个改名为 `HttpMethodType` (#4691, #4535 C14) + +`HttpMethod` 过去被三个入口导出,但**不是同一个声明**,拿到哪个只取决于 import 路径 —— #4411 陷阱。而且与 C11(`HttpRequest`)不同,这一簇两侧连**取值集合都不一样**: + +| 入口 | 声明位置 | 取值 | +|:--|:--|:--| +| `@objectstack/spec/shared`、`@objectstack/spec/api`(**不变**) | `shared/http.zod.ts` 的 `z.enum([...])` | **7 值** — `GET` `POST` `PUT` `DELETE` `PATCH` **`HEAD` `OPTIONS`** | +| `@objectstack/spec/ui`(**本次移除**) | `ui/view.zod.ts` 的 `z.infer< typeof HttpMethodSchema >` | **5 值** — `GET` `POST` `PUT` `PATCH` `DELETE` | + +7 值那个描述的是「HTTP 协议本身有哪些方法」(CORS `methods[]`、REST 路由表、endpoint 声明都用它);5 值那个是 UI/View 数据源允许配置的**真子集**,它的注释自己写着 *"HTTP Method Schema (subset for UI/View data sources)"*。 + +## FROM → TO + +```ts +// FROM —— 拿到的是 5 值的 UI 子集类型 +import type { HttpMethod } from '@objectstack/spec/ui'; + +// TO —— 同一个类型,同一个入口,零形状变化 +import type { HttpMethodType } from '@objectstack/spec/ui'; +``` + +`HttpMethodType` 是 `shared/http.zod.ts` 里 `z.infer< typeof HttpMethodSchema >` 的既有名字(`@objectstack/spec/shared` 一直在导出),本次由 `./ui` **re-export** 同一个声明,所以改完之后解析到的类型与改之前逐字相同。 + +⚠️ **不要把 import 路径改成 `@objectstack/spec/shared` 而保留 `HttpMethod` 这个名字。** 那里的 `HttpMethod` 是**7 值**的那一个,会把类型悄悄放宽两个值,而 `HttpRequestSchema.method` 运行时只接受 5 值 —— `method: 'HEAD'` 会通过编译、在 `.parse()` 抛错。之所以把 `HttpMethodType` 也从 `./ui` re-export 出去,就是为了让编译器的 "did you mean" 指向同一入口里正确的那个名字,而不是引诱这次换路径。 + +用 7 值枚举的代码不受影响:`import { HttpMethod } from '@objectstack/spec/api'`(或 `/shared`)行为一字未变。 + +## 为什么不是「让 `./ui` re-export `./shared` 的 `HttpMethod`」 + +C11(#4688)对 `HttpRequest` 用的正是这一招,当时是对的 —— 两侧 `z.infer` 的是**同一个 schema 对象**,形状逐字段相同,收敛后消费者零感知。 + +本簇不成立:那样做会把 `./ui` 的 `HttpMethod` 从 5 值放宽到 7 值,而 `HttpRequestSchema.method`(`shared/http.zod.ts`)校验用的仍是 5 值的 `HttpMethodSchema`。结果是**类型开始对运行时说谎** —— 少一行基线,换来一个编译期放行、运行期抛错的坑。所以走的是「`./ui` 不再叫这个名字」,让 5 值类型保留它在 `./shared` 里已有的诚实名字。 + +`HttpMethodSchema` 的值域**一字未动**(仍是 5 值),`HttpRequestSchema` 的运行时行为**零变化**。 + +## 定级理由(逐条自证,未照抄前例) + +定 **major**,因为这是一次**已发布导出名的移除**:外部 `import type { HttpMethod } from '@objectstack/spec/ui'` 会以 TS2305 编译失败。这与 C11 定 patch 的情形正相反 —— 那次名字仍在导出、只是换了声明来源,消费者无需改一个字符;本次名字没了。 + +同时它**不是元数据破坏**: +- `authorable-surface.json` **零变化**(实跑 `check:authorable-surface` ✓)—— `HttpMethod` 是纯 TS 类型别名,不是可作者化的 key。 +- 因此**无 tombstone、无 ADR-0087 conversion / migration**,`spec-changes.json` 与 `protocol-upgrade-guide.md` 零变化(两个 gate 均 ✓)。 +- 已存 `sys_metadata` 数据、JSON Schema 产物、运行时校验行为全部不受影响。 + +也就是说:**零元数据迁移,只有一处一行的 TypeScript import 改动。** + +## 基线 14 → 13 + +`dual-source-exports.baseline.json` 删掉 `HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]` 一行,其余 13 行一字未动。这是 #4535 v17 双源账的**最后一条**。 + +`api-surface.json` 的改动恰好只有 `./ui` 的一行对换:`- HttpMethod (type)` / `+ HttpMethodType (type)`。 diff --git a/content/docs/references/ui/http.mdx b/content/docs/references/ui/http.mdx index aab404fa19..0d9c021cec 100644 --- a/content/docs/references/ui/http.mdx +++ b/content/docs/references/ui/http.mdx @@ -9,7 +9,7 @@ description: Http protocol schemas ```typescript import { HttpMethodSchema, HttpRequestSchema } from '@objectstack/spec/ui'; -import type { HttpMethod, HttpRequest } from '@objectstack/spec/ui'; +import type { HttpRequest } from '@objectstack/spec/ui'; // Validate data const result = HttpMethodSchema.parse(data); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index f11c637c79..9be351fd8c 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3394,8 +3394,8 @@ "GroupingConfig (type)", "GroupingConfigSchema (const)", "GroupingFieldSchema (const)", - "HttpMethod (type)", "HttpMethodSchema (const)", + "HttpMethodType (type)", "HttpRequest (type)", "HttpRequestSchema (const)", "I18nLabel (type)", diff --git a/packages/spec/docs-import-surface.baseline.json b/packages/spec/docs-import-surface.baseline.json index cada1c70cf..f7165d3249 100644 --- a/packages/spec/docs-import-surface.baseline.json +++ b/packages/spec/docs-import-surface.baseline.json @@ -123,6 +123,7 @@ "ui/GanttConfig — no type export", "ui/GanttQuickFilter — no type export", "ui/GroupingField — no type export", + "ui/HttpMethod — no type export", "ui/KanbanConfig — no type export", "ui/NavigationMode — no type export", "ui/ObjectListView — no type export", diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json index 7fc7647580..d99af4811d 100644 --- a/packages/spec/dual-source-exports.baseline.json +++ b/packages/spec/dual-source-exports.baseline.json @@ -10,7 +10,6 @@ "EnvironmentArtifactInput — [./cloud (type)] ≠ [./system (type)]", "EnvironmentArtifactSchema — [./cloud (const)] ≠ [./system (const)]", "EventSchema — [./automation (const)] ≠ [./kernel (const)]", - "HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]", "PackageDependency — [./cloud (type)] ≠ [./kernel (type)]", "PackageDependencySchema — [./cloud (const)] ≠ [./kernel (const)]", "TenantPlan — [./cloud (type)] ≠ [./system (type)]", diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 8a6e971b5f..2bb211a80a 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2736,6 +2736,126 @@ describe('[#4688] HttpRequest is single-source across ./shared and ./ui', () => }); }); +// ─── [#4691] `HttpMethod` is gone from ./ui — the LAST dual-source row ─────── +// +// The sibling of the #4688 pin above, and deliberately NOT the same fix. There, +// `./shared` and `./ui` named two declarations of the *same* shape and the cure +// was a re-export. Here the two declarations are genuinely different types: +// +// shared/http.zod.ts `export const/type HttpMethod` → 7 values (+HEAD/OPTIONS) +// shared/http.zod.ts `HttpMethodSchema`/`HttpMethodType` → 5 values (UI subset) +// ui/view.zod.ts `export type HttpMethod` (removed) → the 5-value one +// +// So re-exporting `./shared`'s into `./ui` would have widened the UI type to 7 +// while `HttpRequestSchema.method` still accepts only 5 — a type that lies about +// its own runtime. The name was removed from `./ui` instead. +// +// Same reasoning as #4688 on the mechanism: `HttpMethod` is a TYPE, erased +// before any runtime assertion can see it, and #4642 established that a +// compile-time pin in this package is a no-op (`tsconfig.json` excludes +// `**/*.test.ts`; vitest never enables `typecheck`). The compiler-API test is +// therefore the load-bearing one; the runtime tests below it guard the value +// ranges the whole argument rests on. +describe('[#4691] `HttpMethod` is not exported from ./ui', () => { + it('resolves the export surface: ./ui has no `HttpMethod`, ./shared and ./api share one', async () => { + const ts = (await import('typescript')).default; + const { resolve, relative, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + + const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + const entries = { + './shared': resolve(specDir, 'src/shared/index.ts'), + './ui': resolve(specDir, 'src/ui/index.ts'), + './api': resolve(specDir, 'src/api/index.ts'), + }; + const program = ts.createProgram(Object.values(entries), { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + skipLibCheck: true, + noEmit: true, + }); + const checker = program.getTypeChecker(); + const unalias = (s: import('typescript').Symbol) => + s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; + + const exportsOf = (sub: keyof typeof entries) => { + const sf = program.getSourceFile(entries[sub]); + const moduleSym = sf && checker.getSymbolAtLocation(sf); + // Without this guard a resolution failure would make every assertion + // below pass vacuously — the exact way a gate goes dormant (#4642). + expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy(); + return checker.getExportsOfModule(moduleSym!); + }; + + // A sanity anchor: if this entry resolved to nothing, `find` returning + // undefined for `HttpMethod` below would prove nothing at all. + const uiExports = exportsOf('./ui'); + expect(uiExports.length, './ui must export a non-trivial surface').toBeGreaterThan(50); + + // 1. The row this change removes: `./ui` no longer names `HttpMethod`. + expect(uiExports.map((e) => e.getName())).not.toContain('HttpMethod'); + + // 2. …but it still offers the 5-value type under its honest name, so the + // migration stays inside this entry point. + const uiMethodType = uiExports.find((e) => e.getName() === 'HttpMethodType'); + expect(uiMethodType, './ui must export `HttpMethodType`').toBeTruthy(); + + const originOf = (sym: import('typescript').Symbol, label: string) => { + const decl = unalias(sym).declarations?.[0]; + expect(decl, `${label} must have a declaration`).toBeTruthy(); + const declFile = decl!.getSourceFile(); + return `${relative(specDir, declFile.fileName)}:${ + declFile.getLineAndCharacterOfPosition(decl!.getStart()).line + 1 + }`; + }; + + expect(originOf(uiMethodType!, './ui HttpMethodType')) + .toMatch(/^src\/shared\/http\.zod\.ts:\d+$/); + + // 3. `./shared` and `./api` keep naming ONE declaration `HttpMethod` — the + // 7-value one. This change must not have disturbed that side. + const origins = new Map(); + for (const sub of ['./shared', './api'] as const) { + const exported = exportsOf(sub).find((e) => e.getName() === 'HttpMethod'); + expect(exported, `${sub} must still export \`HttpMethod\``).toBeTruthy(); + origins.set(sub, originOf(exported!, `${sub} HttpMethod`)); + } + expect(origins.get('./api')).toBe(origins.get('./shared')); + expect(origins.get('./shared')).toMatch(/^src\/shared\/http\.zod\.ts:\d+$/); + }); + + it('keeps the two value ranges distinct: 7 for `HttpMethod`, 5 for `HttpMethodSchema`', async () => { + const sharedEntry = await import('../shared/index'); + const apiEntry = await import('../api/index'); + + // `./api` re-exports the const, so this is one object seen twice. + expect(apiEntry.HttpMethod).toBe(sharedEntry.HttpMethod); + + expect([...sharedEntry.HttpMethod.options].sort()).toEqual( + ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'], + ); + expect([...sharedEntry.HttpMethodSchema.options].sort()).toEqual( + ['DELETE', 'GET', 'PATCH', 'POST', 'PUT'], + ); + + // The subset relation is the whole reason the two names cannot merge. + expect(sharedEntry.HttpMethod.options).toContain('HEAD'); + expect(sharedEntry.HttpMethodSchema.options).not.toContain('HEAD'); + }); + + it('rejects `HEAD` at the parse layer — the runtime the ./ui type must not out-promise', async () => { + const uiEntry = await import('../ui/index'); + + expect(() => uiEntry.HttpMethodSchema.parse('HEAD')).toThrow(); + expect(() => uiEntry.HttpMethodSchema.parse('OPTIONS')).toThrow(); + expect(() => uiEntry.HttpRequestSchema.parse({ url: '/api/data', method: 'HEAD' })).toThrow(); + // …while the 5 it does accept still round-trip, so the guard above is not + // passing because the schema rejects everything. + expect(uiEntry.HttpRequestSchema.parse({ url: '/api/data', method: 'PATCH' }).method) + .toBe('PATCH'); + }); +}); + describe('ADR-0089 — visibleWhen unification (view form)', () => { it('normalizes a deprecated `visibleOn` alias to `visibleWhen` on a form field', () => { const parsed = FormFieldSchema.parse({ field: 'state', visibleOn: "record.country == 'US'" }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 204fffcc89..f5db4d1b1f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -53,6 +53,34 @@ export { HttpMethodSchema, HttpRequestSchema }; */ export type { HttpRequest } from '../shared/http.zod'; +/** + * [#4691] The type of `HttpMethodSchema` is `HttpMethodType`, RE-EXPORTED from + * its one declaration in `shared/http.zod.ts`. + * + * `./ui` used to export that same 5-value type under the name `HttpMethod` + * (`export type HttpMethod = z.infer< typeof HttpMethodSchema >`, in the alias + * block at the bottom of this file). That name is already taken across the + * package by a DIFFERENT declaration — `shared/http.zod.ts`'s 7-value + * `z.enum([… 'HEAD', 'OPTIONS'])`, exported by `./shared` and `./api` — so one + * name resolved to two incompatible types depending on the import path (the + * #4411 trap; the last row of `dual-source-exports.baseline.json`). + * + * Converging by re-exporting `./shared`'s `HttpMethod` here — the fix #4688 + * used for `HttpRequest` — would have been WRONG: it silently widens `./ui`'s + * type from 5 values to 7 while `HttpRequestSchema.method` still validates + * against the 5-value `HttpMethodSchema`. `method: 'HEAD'` would type-check and + * then throw at `.parse()` — the type would start lying about the runtime. So + * the NAME is dropped from `./ui` instead, and the honest 5-value type keeps + * the name it already carries in `./shared`. + * + * Re-exported here (rather than only left in `./shared`) so the shortest fix + * for `import type { HttpMethod } from '@objectstack/spec/ui'` is also the + * CORRECT one: TypeScript's "did you mean" points at `HttpMethodType` in the + * same entry point, instead of tempting a path swap to `./shared`, where the + * name `HttpMethod` does still exist and means the wider 7-value enum. + */ +export type { HttpMethodType } from '../shared/http.zod'; + /** * View Data Source Configuration * Supports three modes: @@ -2058,7 +2086,14 @@ export type ViewData = z.infer; // this file (#4688). Re-adding `= z.infer` below would // re-create the dual-source row this change removed, even though the inferred // shape is identical. -export type HttpMethod = z.infer; +// +// `HttpMethod` is not declared here either — and is no longer exported from +// `./ui` at all (#4691). It used to be `= z.infer`, +// the 5-value UI subset, while `./shared` and `./api` export a DIFFERENT, +// 7-value declaration under that same name. Re-exporting theirs would widen +// this entry's type past what `HttpRequestSchema.method` actually validates, so +// the name was dropped instead; the 5-value type is re-exported as +// `HttpMethodType` at the top of this file, where the full rationale lives. export type ColumnSummary = z.infer; export type ColumnSummaryConfig = z.infer; export type ColumnPrefix = z.infer;