Skip to content

Commit ccf8280

Browse files
committed
refactor(spec): converge HttpRequest onto one declaration (#4688, C11)
`@objectstack/spec/shared` and `@objectstack/spec/ui` both exported the name `HttpRequest` for DIFFERENT type declarations — a row on `dual-source-exports.baseline.json`, judged by symbol identity (#4411 trap). This cluster is the degenerate case of that trap. `HttpRequestSchema` was never duplicated: `ui/view.zod.ts` imports it from `shared/http.zod.ts` and re-exports it verbatim, which is why the baseline carries no `HttpRequestSchema` row. The only split was the local type alias at the bottom of view.zod.ts — `z.infer<typeof HttpRequestSchema>` over the very same schema object, so a second declaration symbol carrying an identical shape. It is now a re-export of shared's declaration, which the baseline header explicitly does not count. Baseline: 19 -> 18. NOT breaking, and deliberately not labelled so. #4535 calls the three v17 clusters breaking wholesale; this one is verified otherwise. FROM and TO infer from one schema object, and the compiler agrees: `Equal<PreFixUiHttpRequest, UiHttpRequest>` and `Equal<PreFixUiHttpRequest, SharedHttpRequest>` both hold, with a deliberately-false negative control erroring TS2344 to prove the pair is not vacuous. api-surface.json, authorable-surface.json and spec-changes.json are all byte-identical after a rebuild — zero authorable key movement, zero tombstone, zero conversion. Overstating breakage pollutes the upgrade guide as surely as understating it, so the changeset is patch. Regression pin: three RUNTIME assertions in ui/view.test.ts. #4642 established that a compile-time pin in this package is dead text (tsconfig excludes **/*.test.ts, vitest never type-checks), and that applies to the pre-existing `type HttpRequest` import in that file too — it is erased, so it never guaranteed the export survives. The third assertion closes that hole by resolving symbol identity through the TypeScript API over src/, the same measurement check:dual-source-exports makes over dist. All three sabotage-verified. `HttpMethod` on the next line is the identical shape and stays — #4535 scheduled it for v18, and scope is the maintainer's to set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9
1 parent f2445c9 commit ccf8280

4 files changed

Lines changed: 170 additions & 2 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
refactor(spec): 双源 C11 收敛 — `HttpRequest` 类型别名改为 re-export `./shared` 的唯一声明 (#4688)
6+
7+
`HttpRequest` 这个名字过去在 `@objectstack/spec/shared``@objectstack/spec/ui` 解析到**两份不同的类型声明**,是 `dual-source-exports.baseline.json` 上的一行(#4411 陷阱)。现在 `./ui` 直接 re-export `./shared` 的那一份,平台只剩一个声明。
8+
9+
基线 **19 → 18**
10+
11+
## 为什么是 patch 而不是 major —— 消费者侧零类型差异,已实证
12+
13+
#4535 主单把 v17 的三个双源簇统称 breaking。**本簇不是**,原因是这一簇和其它簇形状不同:
14+
15+
`HttpRequestSchema` **从来只有一份声明**(在 `shared/http.zod.ts`)。`ui/view.zod.ts` 一直是 `import` 进来再原样 re-export 的,所以基线里根本没有 `HttpRequestSchema` 行。被判为双源的只有 `ui/view.zod.ts` 底部那个**本地类型别名**:
16+
17+
```ts
18+
// FROM —— ./ui 的本地 infer(第二个类型声明符号)
19+
export type HttpRequest = z.infer< typeof HttpRequestSchema >;
20+
21+
// TO —— re-export ./shared 的唯一声明
22+
export type { HttpRequest } from '../shared/http.zod';
23+
```
24+
25+
两者 `z.infer` 的是**同一个** schema 对象,所以解析出来的类型逐字段相同。这不是推断,是编译器验过的:
26+
27+
```ts
28+
type Equal< X, Y > = (< T >() => T extends X ? 1 : 2) extends (< T >() => T extends Y ? 1 : 2) ? true : false;
29+
type Assert< T extends true > = T;
30+
31+
type PreFixUiHttpRequest = z.infer< typeof HttpRequestSchema >; // FROM,逐字复刻旧那行
32+
type _A = Assert< Equal< PreFixUiHttpRequest, SharedHttpRequest > >; // ✅ 通过
33+
type _B = Assert< Equal< PreFixUiHttpRequest, UiHttpRequest > >; // ✅ 通过(FROM === TO)
34+
type _NEG = Assert< Equal< UiHttpRequest, { totallyDifferent: true } > >; // ❌ TS2344,证明上面两条不是空转
35+
```
36+
37+
配套证据:`api-surface.json` 零改动(名字、入口、kind 全部不变),`authorable-surface.json` 零改动,无 tombstone,无 ADR-0087 conversion —— 因为没有任何可作者化的 key 或运行时行为发生变化。
38+
39+
**所以升级者无需做任何事。** 没有 FROM → TO 迁移动作,`import type { HttpRequest } from '@objectstack/spec/ui'``from '@objectstack/spec/shared'` 都照旧可用,且现在保证指向同一个声明。谎报破坏和漏报破坏一样会污染升级指南,故按实际情况定级为 patch。
40+
41+
## 回归 pin
42+
43+
`src/ui/view.test.ts` 新增三条**运行时**断言(#4642 已证本包的编译期 pin 空转:`tsconfig.json` 排除 `**/*.test.ts`,vitest 也不开 `typecheck`)。其中第三条用 TypeScript compiler API 在 `src/` 上做符号身份解析 —— 因为 `HttpRequest`**类型**,运行时看不见它,而这恰恰是本簇唯一改动的东西。三条已 sabotage 验证会红:
44+
45+
- 还原旧的本地 infer 别名 → `expected 'src/ui/view.zod.ts:2056' to be 'src/shared/http.zod.ts:54'`
46+
- 删掉 re-export 不补 → ``./ui must still export the name `HttpRequest` ``
47+
-`./ui` 重新声明第二份 `HttpRequestSchema` → 运行时身份断言失败
48+
49+
## 未纳入:紧邻的 `HttpMethod`
50+
51+
`ui/view.zod.ts` 下一行的 `export type HttpMethod = z.infer< typeof HttpMethodSchema >`**完全相同的形状**,基线行 `HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]` 仍在。#4535 已把它排进 v18,范围由维护者定,故本 PR 不动它。

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
"FieldMapping — [./data (type)] ≠ [./integration (type)] ≠ [./shared (type)]",
1414
"FieldMappingSchema — [./data (const)] ≠ [./integration (const)] ≠ [./shared (const)]",
1515
"HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]",
16-
"HttpRequest — [./shared (type)] ≠ [./ui (type)]",
1716
"PackageDependency — [./cloud (type)] ≠ [./kernel (type)]",
1817
"PackageDependencySchema — [./cloud (const)] ≠ [./kernel (const)]",
1918
"RateLimitConfig — [./integration (type)] ≠ [./shared (type)]",

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2639,6 +2639,103 @@ describe('HttpMethodSchema/HttpRequestSchema backward compat', () => {
26392639
});
26402640
});
26412641

2642+
// ─── [#4688] Dual-source regression pin ──────────────────────────────
2643+
//
2644+
// RUNTIME assertions, deliberately. #4642 established that a compile-time pin in
2645+
// `packages/spec` is a no-op: `tsconfig.json` excludes `**/*.test.ts` and
2646+
// `vitest.config.ts` never enables `typecheck`, so neither path type-checks a
2647+
// test file. A conditional-type `Assert< Equal< … > >` here would be dead text —
2648+
// and so, for the same reason, is the bare `type HttpRequest` import at the top
2649+
// of this file: vitest's transform erases it, so it proves nothing about the
2650+
// export still existing. The third test below is what actually proves that.
2651+
//
2652+
// What these defend: `HttpRequest` naming ONE declaration across both published
2653+
// entries. `HttpRequestSchema` was never duplicated — `./ui` imports and
2654+
// re-exports `./shared`'s const — so the only thing that ever split was the type
2655+
// alias, which is exactly the part runtime cannot see. Hence two layers.
2656+
describe('[#4688] HttpRequest is single-source across ./shared and ./ui', () => {
2657+
it('both entry points expose the very same schema declaration at runtime', async () => {
2658+
const sharedEntry = await import('../shared/index');
2659+
const uiEntry = await import('../ui/index');
2660+
2661+
// Identity, not shape: `lazySchema` returns one Proxy per declaration site,
2662+
// so two declarations could never be `toBe`-equal however alike they look.
2663+
// A re-introduced local `HttpRequestSchema` in view.zod.ts fails here.
2664+
expect(uiEntry.HttpRequestSchema).toBe(sharedEntry.HttpRequestSchema);
2665+
});
2666+
2667+
it('the shared declaration validates identically on both entries', async () => {
2668+
const sharedEntry = await import('../shared/index');
2669+
const uiEntry = await import('../ui/index');
2670+
2671+
for (const [entry, schema] of [
2672+
['./shared', sharedEntry.HttpRequestSchema],
2673+
['./ui', uiEntry.HttpRequestSchema],
2674+
] as const) {
2675+
expect(schema.parse({ url: '/api/data' }), `${entry} defaults method to GET`)
2676+
.toEqual({ url: '/api/data', method: 'GET' });
2677+
expect(() => schema.parse({}), `${entry} requires url`).toThrow();
2678+
}
2679+
});
2680+
2681+
// The load-bearing one. `HttpRequest` is a TYPE — erased before any runtime
2682+
// assertion can see it — so the two tests above would stay green if the
2683+
// re-export were deleted or replaced by a second local `z.infer` alias, which
2684+
// is the entire defect #4688 fixed. This resolves the export through its alias
2685+
// chain to the ORIGINAL declaration: the same symbol-identity measurement
2686+
// `check:dual-source-exports` makes, but over `src/` so it runs in `pnpm test`
2687+
// without a build. It also pins that `./ui` still EXPORTS the name at all —
2688+
// the compatibility promise this file's own `type HttpRequest` import rests on.
2689+
it('both entry points resolve the TYPE to the one declaration in shared/http.zod.ts', async () => {
2690+
const ts = (await import('typescript')).default;
2691+
const { resolve, relative, dirname } = await import('node:path');
2692+
const { fileURLToPath } = await import('node:url');
2693+
2694+
const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
2695+
const entries = {
2696+
'./shared': resolve(specDir, 'src/shared/index.ts'),
2697+
'./ui': resolve(specDir, 'src/ui/index.ts'),
2698+
};
2699+
const program = ts.createProgram(Object.values(entries), {
2700+
module: ts.ModuleKind.ESNext,
2701+
moduleResolution: ts.ModuleResolutionKind.Bundler,
2702+
skipLibCheck: true,
2703+
noEmit: true,
2704+
});
2705+
const checker = program.getTypeChecker();
2706+
const unalias = (s: import('typescript').Symbol) =>
2707+
s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s;
2708+
2709+
const origins = new Map<string, string>();
2710+
for (const [sub, file] of Object.entries(entries)) {
2711+
const sf = program.getSourceFile(file);
2712+
const moduleSym = sf && checker.getSymbolAtLocation(sf);
2713+
// Without this, a resolution failure would make every assertion below
2714+
// pass vacuously — the exact way a gate goes dormant.
2715+
expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy();
2716+
2717+
const exported = checker
2718+
.getExportsOfModule(moduleSym!)
2719+
.find((e) => e.getName() === 'HttpRequest');
2720+
expect(exported, `${sub} must still export the name \`HttpRequest\``).toBeTruthy();
2721+
2722+
const decl = unalias(exported!).declarations?.[0];
2723+
expect(decl, `${sub}'s HttpRequest must have a declaration`).toBeTruthy();
2724+
const declFile = decl!.getSourceFile();
2725+
origins.set(
2726+
sub,
2727+
`${relative(specDir, declFile.fileName)}:${
2728+
declFile.getLineAndCharacterOfPosition(decl!.getStart()).line + 1
2729+
}`,
2730+
);
2731+
}
2732+
2733+
// Same file AND same line — one declaration reached by two import paths.
2734+
expect(origins.get('./ui')).toBe(origins.get('./shared'));
2735+
expect(origins.get('./shared')).toMatch(/^src\/shared\/http\.zod\.ts:\d+$/);
2736+
});
2737+
});
2738+
26422739
describe('ADR-0089 — visibleWhen unification (view form)', () => {
26432740
it('normalizes a deprecated `visibleOn` alias to `visibleWhen` on a form field', () => {
26442741
const parsed = FormFieldSchema.parse({ field: 'state', visibleOn: "record.country == 'US'" });

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@ const VIEW_HISTORY =
3636

3737
export { HttpMethodSchema, HttpRequestSchema };
3838

39+
/**
40+
* [#4688] `HttpRequest` is RE-EXPORTED from its one declaration in
41+
* `shared/http.zod.ts` — never re-inferred here.
42+
*
43+
* The line this replaces was `export type HttpRequest = z.infer< typeof
44+
* HttpRequestSchema >` in the alias block at the bottom of this file. It looked
45+
* single-source: it inferred from the very schema object imported above, so the
46+
* resolved shape was identical. But it was a SECOND type declaration carrying
47+
* one name, and symbol identity — not shape — is what
48+
* `check:dual-source-exports` measures, and what an auto-import or a model
49+
* completion resolves by. That is how `./shared` and `./ui` came to name two
50+
* different declarations `HttpRequest` (the #4411 trap). A re-export keeps every
51+
* existing `import type { HttpRequest } from '@objectstack/spec/ui'` working
52+
* while leaving exactly one declaration that could ever diverge.
53+
*/
54+
export type { HttpRequest } from '../shared/http.zod';
55+
3956
/**
4057
* View Data Source Configuration
4158
* Supports three modes:
@@ -2036,7 +2053,11 @@ export type SelectionConfig = z.infer<typeof SelectionConfigSchema>;
20362053
export type NavigationConfig = z.infer<typeof NavigationConfigSchema>;
20372054
export type PaginationConfig = z.infer<typeof PaginationConfigSchema>;
20382055
export type ViewData = z.infer<typeof ViewDataSchema>;
2039-
export type HttpRequest = z.infer<typeof HttpRequestSchema>;
2056+
// `HttpRequest` is NOT inferred here — it is re-exported from its single
2057+
// declaration in `shared/http.zod.ts` next to the schema re-export at the top of
2058+
// this file (#4688). Re-adding `= z.infer<typeof HttpRequestSchema>` below would
2059+
// re-create the dual-source row this change removed, even though the inferred
2060+
// shape is identical.
20402061
export type HttpMethod = z.infer<typeof HttpMethodSchema>;
20412062
export type ColumnSummary = z.infer<typeof ColumnSummarySchema>;
20422063
export type ColumnSummaryConfig = z.infer<typeof ColumnSummaryConfigSchema>;

0 commit comments

Comments
 (0)