From ccf8280eeffebc8c59b9f64b93611f885add29d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 17:43:29 +0000 Subject: [PATCH] refactor(spec): converge HttpRequest onto one declaration (#4688, C11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@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` 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` and `Equal` 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 Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9 --- .changeset/http-request-dual-source-c11.md | 51 ++++++++++ .../spec/dual-source-exports.baseline.json | 1 - packages/spec/src/ui/view.test.ts | 97 +++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 23 ++++- 4 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 .changeset/http-request-dual-source-c11.md diff --git a/.changeset/http-request-dual-source-c11.md b/.changeset/http-request-dual-source-c11.md new file mode 100644 index 0000000000..ff5aa5d983 --- /dev/null +++ b/.changeset/http-request-dual-source-c11.md @@ -0,0 +1,51 @@ +--- +"@objectstack/spec": patch +--- + +refactor(spec): 双源 C11 收敛 — `HttpRequest` 类型别名改为 re-export `./shared` 的唯一声明 (#4688) + +`HttpRequest` 这个名字过去在 `@objectstack/spec/shared` 和 `@objectstack/spec/ui` 解析到**两份不同的类型声明**,是 `dual-source-exports.baseline.json` 上的一行(#4411 陷阱)。现在 `./ui` 直接 re-export `./shared` 的那一份,平台只剩一个声明。 + +基线 **19 → 18**。 + +## 为什么是 patch 而不是 major —— 消费者侧零类型差异,已实证 + +#4535 主单把 v17 的三个双源簇统称 breaking。**本簇不是**,原因是这一簇和其它簇形状不同: + +`HttpRequestSchema` **从来只有一份声明**(在 `shared/http.zod.ts`)。`ui/view.zod.ts` 一直是 `import` 进来再原样 re-export 的,所以基线里根本没有 `HttpRequestSchema` 行。被判为双源的只有 `ui/view.zod.ts` 底部那个**本地类型别名**: + +```ts +// FROM —— ./ui 的本地 infer(第二个类型声明符号) +export type HttpRequest = z.infer< typeof HttpRequestSchema >; + +// TO —— re-export ./shared 的唯一声明 +export type { HttpRequest } from '../shared/http.zod'; +``` + +两者 `z.infer` 的是**同一个** schema 对象,所以解析出来的类型逐字段相同。这不是推断,是编译器验过的: + +```ts +type Equal< X, Y > = (< T >() => T extends X ? 1 : 2) extends (< T >() => T extends Y ? 1 : 2) ? true : false; +type Assert< T extends true > = T; + +type PreFixUiHttpRequest = z.infer< typeof HttpRequestSchema >; // FROM,逐字复刻旧那行 +type _A = Assert< Equal< PreFixUiHttpRequest, SharedHttpRequest > >; // ✅ 通过 +type _B = Assert< Equal< PreFixUiHttpRequest, UiHttpRequest > >; // ✅ 通过(FROM === TO) +type _NEG = Assert< Equal< UiHttpRequest, { totallyDifferent: true } > >; // ❌ TS2344,证明上面两条不是空转 +``` + +配套证据:`api-surface.json` 零改动(名字、入口、kind 全部不变),`authorable-surface.json` 零改动,无 tombstone,无 ADR-0087 conversion —— 因为没有任何可作者化的 key 或运行时行为发生变化。 + +**所以升级者无需做任何事。** 没有 FROM → TO 迁移动作,`import type { HttpRequest } from '@objectstack/spec/ui'` 和 `from '@objectstack/spec/shared'` 都照旧可用,且现在保证指向同一个声明。谎报破坏和漏报破坏一样会污染升级指南,故按实际情况定级为 patch。 + +## 回归 pin + +`src/ui/view.test.ts` 新增三条**运行时**断言(#4642 已证本包的编译期 pin 空转:`tsconfig.json` 排除 `**/*.test.ts`,vitest 也不开 `typecheck`)。其中第三条用 TypeScript compiler API 在 `src/` 上做符号身份解析 —— 因为 `HttpRequest` 是**类型**,运行时看不见它,而这恰恰是本簇唯一改动的东西。三条已 sabotage 验证会红: + +- 还原旧的本地 infer 别名 → `expected 'src/ui/view.zod.ts:2056' to be 'src/shared/http.zod.ts:54'` +- 删掉 re-export 不补 → ``./ui must still export the name `HttpRequest` `` +- 在 `./ui` 重新声明第二份 `HttpRequestSchema` → 运行时身份断言失败 + +## 未纳入:紧邻的 `HttpMethod` + +`ui/view.zod.ts` 下一行的 `export type HttpMethod = z.infer< typeof HttpMethodSchema >` 是**完全相同的形状**,基线行 `HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]` 仍在。#4535 已把它排进 v18,范围由维护者定,故本 PR 不动它。 diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json index 3d2885b5e2..dee39afe8e 100644 --- a/packages/spec/dual-source-exports.baseline.json +++ b/packages/spec/dual-source-exports.baseline.json @@ -13,7 +13,6 @@ "FieldMapping — [./data (type)] ≠ [./integration (type)] ≠ [./shared (type)]", "FieldMappingSchema — [./data (const)] ≠ [./integration (const)] ≠ [./shared (const)]", "HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]", - "HttpRequest — [./shared (type)] ≠ [./ui (type)]", "PackageDependency — [./cloud (type)] ≠ [./kernel (type)]", "PackageDependencySchema — [./cloud (const)] ≠ [./kernel (const)]", "RateLimitConfig — [./integration (type)] ≠ [./shared (type)]", diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index e9468fc7f8..8a6e971b5f 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2639,6 +2639,103 @@ describe('HttpMethodSchema/HttpRequestSchema backward compat', () => { }); }); +// ─── [#4688] Dual-source regression pin ────────────────────────────── +// +// RUNTIME assertions, deliberately. #4642 established that a compile-time pin in +// `packages/spec` is a no-op: `tsconfig.json` excludes `**/*.test.ts` and +// `vitest.config.ts` never enables `typecheck`, so neither path type-checks a +// test file. A conditional-type `Assert< Equal< … > >` here would be dead text — +// and so, for the same reason, is the bare `type HttpRequest` import at the top +// of this file: vitest's transform erases it, so it proves nothing about the +// export still existing. The third test below is what actually proves that. +// +// What these defend: `HttpRequest` naming ONE declaration across both published +// entries. `HttpRequestSchema` was never duplicated — `./ui` imports and +// re-exports `./shared`'s const — so the only thing that ever split was the type +// alias, which is exactly the part runtime cannot see. Hence two layers. +describe('[#4688] HttpRequest is single-source across ./shared and ./ui', () => { + it('both entry points expose the very same schema declaration at runtime', async () => { + const sharedEntry = await import('../shared/index'); + const uiEntry = await import('../ui/index'); + + // Identity, not shape: `lazySchema` returns one Proxy per declaration site, + // so two declarations could never be `toBe`-equal however alike they look. + // A re-introduced local `HttpRequestSchema` in view.zod.ts fails here. + expect(uiEntry.HttpRequestSchema).toBe(sharedEntry.HttpRequestSchema); + }); + + it('the shared declaration validates identically on both entries', async () => { + const sharedEntry = await import('../shared/index'); + const uiEntry = await import('../ui/index'); + + for (const [entry, schema] of [ + ['./shared', sharedEntry.HttpRequestSchema], + ['./ui', uiEntry.HttpRequestSchema], + ] as const) { + expect(schema.parse({ url: '/api/data' }), `${entry} defaults method to GET`) + .toEqual({ url: '/api/data', method: 'GET' }); + expect(() => schema.parse({}), `${entry} requires url`).toThrow(); + } + }); + + // The load-bearing one. `HttpRequest` is a TYPE — erased before any runtime + // assertion can see it — so the two tests above would stay green if the + // re-export were deleted or replaced by a second local `z.infer` alias, which + // is the entire defect #4688 fixed. This resolves the export through its alias + // chain to the ORIGINAL declaration: the same symbol-identity measurement + // `check:dual-source-exports` makes, but over `src/` so it runs in `pnpm test` + // without a build. It also pins that `./ui` still EXPORTS the name at all — + // the compatibility promise this file's own `type HttpRequest` import rests on. + it('both entry points resolve the TYPE to the one declaration in shared/http.zod.ts', 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'), + }; + 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 origins = new Map(); + for (const [sub, file] of Object.entries(entries)) { + const sf = program.getSourceFile(file); + const moduleSym = sf && checker.getSymbolAtLocation(sf); + // Without this, a resolution failure would make every assertion below + // pass vacuously — the exact way a gate goes dormant. + expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy(); + + const exported = checker + .getExportsOfModule(moduleSym!) + .find((e) => e.getName() === 'HttpRequest'); + expect(exported, `${sub} must still export the name \`HttpRequest\``).toBeTruthy(); + + const decl = unalias(exported!).declarations?.[0]; + expect(decl, `${sub}'s HttpRequest must have a declaration`).toBeTruthy(); + const declFile = decl!.getSourceFile(); + origins.set( + sub, + `${relative(specDir, declFile.fileName)}:${ + declFile.getLineAndCharacterOfPosition(decl!.getStart()).line + 1 + }`, + ); + } + + // Same file AND same line — one declaration reached by two import paths. + expect(origins.get('./ui')).toBe(origins.get('./shared')); + expect(origins.get('./shared')).toMatch(/^src\/shared\/http\.zod\.ts:\d+$/); + }); +}); + 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 c835f6181d..204fffcc89 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -36,6 +36,23 @@ const VIEW_HISTORY = export { HttpMethodSchema, HttpRequestSchema }; +/** + * [#4688] `HttpRequest` is RE-EXPORTED from its one declaration in + * `shared/http.zod.ts` — never re-inferred here. + * + * The line this replaces was `export type HttpRequest = z.infer< typeof + * HttpRequestSchema >` in the alias block at the bottom of this file. It looked + * single-source: it inferred from the very schema object imported above, so the + * resolved shape was identical. But it was a SECOND type declaration carrying + * one name, and symbol identity — not shape — is what + * `check:dual-source-exports` measures, and what an auto-import or a model + * completion resolves by. That is how `./shared` and `./ui` came to name two + * different declarations `HttpRequest` (the #4411 trap). A re-export keeps every + * existing `import type { HttpRequest } from '@objectstack/spec/ui'` working + * while leaving exactly one declaration that could ever diverge. + */ +export type { HttpRequest } from '../shared/http.zod'; + /** * View Data Source Configuration * Supports three modes: @@ -2036,7 +2053,11 @@ export type SelectionConfig = z.infer; export type NavigationConfig = z.infer; export type PaginationConfig = z.infer; export type ViewData = z.infer; -export type HttpRequest = z.infer; +// `HttpRequest` is NOT inferred here — it is re-exported from its single +// declaration in `shared/http.zod.ts` next to the schema re-export at the top of +// 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; export type ColumnSummary = z.infer; export type ColumnSummaryConfig = z.infer;