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
51 changes: 51 additions & 0 deletions .changeset/http-request-dual-source-c11.md
Original file line number Diff line number Diff line change
@@ -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 不动它。
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 @@ -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)]",
Expand Down
97 changes: 97 additions & 0 deletions packages/spec/src/ui/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
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'" });
Expand Down
23 changes: 22 additions & 1 deletion packages/spec/src/ui/view.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -2036,7 +2053,11 @@ export type SelectionConfig = z.infer<typeof SelectionConfigSchema>;
export type NavigationConfig = z.infer<typeof NavigationConfigSchema>;
export type PaginationConfig = z.infer<typeof PaginationConfigSchema>;
export type ViewData = z.infer<typeof ViewDataSchema>;
export type HttpRequest = z.infer<typeof HttpRequestSchema>;
// `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<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>;
export type ColumnSummary = z.infer<typeof ColumnSummarySchema>;
export type ColumnSummaryConfig = z.infer<typeof ColumnSummaryConfigSchema>;
Expand Down
Loading