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
55 changes: 55 additions & 0 deletions .changeset/http-method-dual-source-c14.md
Original file line number Diff line number Diff line change
@@ -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)`。
2 changes: 1 addition & 1 deletion content/docs/references/ui/http.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -3394,8 +3394,8 @@
"GroupingConfig (type)",
"GroupingConfigSchema (const)",
"GroupingFieldSchema (const)",
"HttpMethod (type)",
"HttpMethodSchema (const)",
"HttpMethodType (type)",
"HttpRequest (type)",
"HttpRequestSchema (const)",
"I18nLabel (type)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/docs-import-surface.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/dual-source-exports.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)]",
Expand Down
120 changes: 120 additions & 0 deletions packages/spec/src/ui/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
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'" });
Expand Down
37 changes: 36 additions & 1 deletion packages/spec/src/ui/view.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -2058,7 +2086,14 @@ export type ViewData = z.infer<typeof ViewDataSchema>;
// this file (#4688). Re-adding `= z.infer<typeof HttpRequestSchema>` below would
// re-create the dual-source row this change removed, even though the inferred
// shape is identical.
export type HttpMethod = z.infer<typeof HttpMethodSchema>;
//
// `HttpMethod` is not declared here either — and is no longer exported from
// `./ui` at all (#4691). It used to be `= z.infer<typeof HttpMethodSchema>`,
// 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<typeof ColumnSummarySchema>;
export type ColumnSummaryConfig = z.infer<typeof ColumnSummaryConfigSchema>;
export type ColumnPrefix = z.infer<typeof ColumnPrefixSchema>;
Expand Down
Loading