diff --git a/.changeset/http-method-defkey-collision.md b/.changeset/http-method-defkey-collision.md new file mode 100644 index 0000000000..a4bfba9009 --- /dev/null +++ b/.changeset/http-method-defkey-collision.md @@ -0,0 +1,56 @@ +--- +"@objectstack/spec": major +--- + +BREAKING(spec): `@objectstack/spec/shared` 与 `/ui` 改名 `HttpMethodSchema` → `HttpMethodSubsetSchema`、`HttpMethodType` → `HttpMethodSubset`;裸名 `HttpMethod(.json)` 现在全包唯一地指 7 值的路由契约,`HEAD`/`OPTIONS` 回到发布出去的 JSON Schema 与参考页 (#5832) + +`packages/spec/src/shared/http.zod.ts` 在同一个文件里声明了两个**内容不同**的枚举, +而它们经 `schemaNameFromExportKey()` 剥掉 `Schema` 后缀后同名: + +| 导出名 | 值域 | 剥后的发布名 | +|:---|:---|:---| +| `HttpMethod` | **7 值**(含 `HEAD`/`OPTIONS`) | `HttpMethod` | +| `HttpMethodSchema` | 5 值(view 数据源子集) | `HttpMethod` | + +`build-schemas.ts` 对 `generatedSchemas.set(defKey, …)` 是无条件覆盖,后写覆盖前写, +5 值那份按导出枚举顺序排在后面。实测结果:`json-schema/shared/HttpMethod.json`、 +bundled `objectstack.json` 的 `$defs['shared/HttpMethod']`、以及 +`content/docs/references/shared/http#httpmethod` **只描述 5 值那份** —— 而 7 值那份才是 +`api/discovery`、`api/endpoint`、`api/plugin-rest-api`、`api/rest-server`、`api/router` +声明 `method` 字段用的线上契约。任何按发布出去的 JSON Schema 做校验的下游(IDE 自动补全、 +codegen、AI 元数据作者)拿到的都是被截断的那一份,会以为 `HEAD`/`OPTIONS` 非法。 +属于 AGENTS.md「Machine-readable surfaces must not lie」。 + +## 发布面变化 + +- `shared/HttpMethod.json` 的 `enum` 从 5 值 **修正为** 7 值 + (`GET`/`POST`/`PUT`/`DELETE`/`PATCH`/`HEAD`/`OPTIONS`)。这是**修复性契约变化**: + 7 值那份一直是源码里 `api/*` 实际使用的那一个,只是从未被发布出去。 +- 新增发布名 `shared/HttpMethodSubset`(5 值)。 +- `ui/HttpMethod`(5 值)**改名**为 `ui/HttpMethodSubset`,登记在 + `scripts/lib/renamed-defs.ts`。 + +## 迁移 + +```ts +// 5 值子集(view 数据源;`HttpRequestSchema.method` 校验用的就是它) +- import { HttpMethodSchema } from '@objectstack/spec/shared'; // 或 '/ui' ++ import { HttpMethodSubsetSchema } from '@objectstack/spec/shared'; // 或 '/ui' +- import type { HttpMethodType } from '@objectstack/spec/ui'; ++ import type { HttpMethodSubset } from '@objectstack/spec/ui'; +``` + +⚠️ **不要把 `HttpMethodSchema` 直接换成 `HttpMethod`。** `shared/HttpMethod` 是 7 值的那一个, +换过去会把类型悄悄放宽两个值,而 `HttpRequestSchema.method` 运行时仍只接受 5 值 —— +`method: 'HEAD'` 会通过编译、在 `.parse()` 抛错。这正是 #4691 当初拒绝合并两个名字的理由, +本次只是把当年留下的 `HttpMethodType` 这个「因为 `HttpMethod` 被占用才起的名字」换成了 +说明其含义的名字,让发布名、schema const、类型别名三者按本包 +`Schema` / `` 的惯例对齐(ADR-0112 D9:一个名字只指一件事;改名走 #4684 +`RateLimitConfig` 的先例)。运行时值域**一字未动**。 + +## 守卫 + +`build-schemas.ts` 补上「同一个 def key 被两个**不同** schema 写第二次 = 硬报错」 +(`scripts/lib/def-key-collisions.ts`),在两个 ratchet 之前跑 —— 它们都以 def key 计量, +碰撞只产生一个 key,谁也看不见。`export const X = XSchema` 这种自别名(本包 `api`/`system`/`ui` +共 14 处)不算碰撞:两次写的是同一个对象,不可能改变发布出去的内容。 diff --git a/content/docs/references/api/router.mdx b/content/docs/references/api/router.mdx index 9a996d7916..b3ab8dc7b8 100644 --- a/content/docs/references/api/router.mdx +++ b/content/docs/references/api/router.mdx @@ -39,6 +39,8 @@ const result = ConflictResolutionStrategy.parse(data); ## HttpMethod +HTTP method — the full routing vocabulary (`api/*` endpoints, router and REST-server routes). The narrower `HttpMethodSubset` is what view data sources may request. + ### Allowed Values * `GET` @@ -72,7 +74,7 @@ const result = ConflictResolutionStrategy.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | | +| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | HTTP method — the full routing vocabulary (`api/*` endpoints, router and REST-server routes). The narrower `HttpMethodSubset` is what view data sources may request. | | **path** | `string` | ✅ | URL Path pattern | | **category** | `Enum<'system' \| 'api' \| 'auth' \| 'static' \| 'webhook' \| 'plugin'>` | ✅ | | | **handler** | `string` | ✅ | Unique handler identifier | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index dbbcebd7e5..157c7c1d89 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1609 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1610 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -29,11 +29,11 @@ counts are sums of the rows they head. Regenerate with | [Kernel Protocol](/docs/references/kernel) | 31 | 187 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Qa Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | | [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. | -| [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | +| [Shared Protocol](/docs/references/shared) | 8 | 32 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 17 | 155 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **201** | **1609** | 14 protocol modules | +| **Total** | **201** | **1610** | 14 protocol modules | --- @@ -285,7 +285,7 @@ Permission sets, row-level security, sharing rules, tenancy posture. ## Shared Protocol -**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **8 pages, 31 schemas** +**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **8 pages, 32 schemas** Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. @@ -294,7 +294,7 @@ Primitives used across every protocol — identifiers, HTTP, expressions, error | [`branded-types.zod.ts`](/docs/references/shared/branded-types) | `AppName`, `FieldName`, `FlowName`, `ObjectName`, `RoleName`, `ViewName` | | [`enums.zod.ts`](/docs/references/shared/enums) | `IsolationLevelEnum`, `MutationEventEnum`, `SortDirectionEnum`, `SortItem` | | [`expression.zod.ts`](/docs/references/shared/expression) | `CronExpressionInput`, `Expression`, `ExpressionDialect`, `ExpressionInput`, `ExpressionMeta`, `Predicate`, `PredicateInput`, `TemplateExpressionInput` | -| [`http.zod.ts`](/docs/references/shared/http) | `CorsConfig`, `HttpMethod`, `HttpRequest`, `RateLimitConfig`, `StaticMount` | +| [`http.zod.ts`](/docs/references/shared/http) | `CorsConfig`, `HttpMethod`, `HttpMethodSubset`, `HttpRequest`, `RateLimitConfig`, `StaticMount` | | [`identifiers.zod.ts`](/docs/references/shared/identifiers) | `EventName`, `SnakeCaseIdentifier`, `SystemIdentifier` | | [`mapping.zod.ts`](/docs/references/shared/mapping) | `FieldMapping`, `FieldMappingTransform` | | [`metadata-types.zod.ts`](/docs/references/shared/metadata-types) | `BaseMetadataRecord`, `MetadataFormat` | @@ -387,7 +387,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`responsive.zod.ts`](/docs/references/ui/responsive) | `BreakpointColumnMap`, `BreakpointName`, `BreakpointOrderMap`, `ResponsiveConfig`, `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | | [`theme.zod.ts`](/docs/references/ui/theme) | `BorderRadius`, `ColorPalette`, `Shadow`, `Theme`, `ThemeMode`, `Typography` | -| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `FormButtonConfig`, `FormField`, `FormSection`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethod`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | +| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `FormButtonConfig`, `FormField`, `FormSection`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | | [`widget.zod.ts`](/docs/references/ui/widget) | `WidgetEvent`, `WidgetLifecycle`, `WidgetManifest`, `WidgetProperty`, `WidgetSource` | --- diff --git a/content/docs/references/shared/http.mdx b/content/docs/references/shared/http.mdx index a2c11b57be..2c73380148 100644 --- a/content/docs/references/shared/http.mdx +++ b/content/docs/references/shared/http.mdx @@ -18,8 +18,8 @@ These schemas ensure consistency across different parts of the stack. ## TypeScript Usage ```typescript -import { CorsConfigSchema, HttpMethodSchema, HttpRequestSchema, RateLimitConfigSchema, StaticMountSchema } from '@objectstack/spec/shared'; -import type { CorsConfig, HttpMethod, HttpRequest, RateLimitConfig, StaticMount } from '@objectstack/spec/shared'; +import { CorsConfigSchema, HttpMethod, HttpMethodSubsetSchema, HttpRequestSchema, RateLimitConfigSchema, StaticMountSchema } from '@objectstack/spec/shared'; +import type { CorsConfig, HttpMethod, HttpMethodSubset, HttpRequest, RateLimitConfig, StaticMount } from '@objectstack/spec/shared'; // Validate data const result = CorsConfigSchema.parse(data); @@ -44,6 +44,25 @@ const result = CorsConfigSchema.parse(data); ## HttpMethod +HTTP method — the full routing vocabulary (`api/*` endpoints, router and REST-server routes). The narrower `HttpMethodSubset` is what view data sources may request. + +### Allowed Values + +* `GET` +* `POST` +* `PUT` +* `DELETE` +* `PATCH` +* `HEAD` +* `OPTIONS` + + +--- + +## HttpMethodSubset + +HTTP methods a view data source may request — the subset of `HttpMethod` without `HEAD`/`OPTIONS`. + ### Allowed Values * `GET` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index d36eb4cd69..260c3f9c14 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -16,8 +16,8 @@ Migrated to [shared/http.zod.ts](/docs/references/shared/http). Re-exported here ## TypeScript Usage ```typescript -import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; -import type { AddRecordConfig, AppearanceConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, FormButtonConfig, FormField, FormSection, FormView, GalleryConfig, GroupingConfig, HttpRequest, ListChartConfig, ListColumn, ListView, NavigationConfig, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui'; +import type { AddRecordConfig, AppearanceConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, FormButtonConfig, FormField, FormSection, FormView, GalleryConfig, GroupingConfig, HttpMethodSubset, HttpRequest, ListChartConfig, ListColumn, ListView, NavigationConfig, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; // Validate data const result = AddRecordConfigSchema.parse(data); @@ -307,7 +307,9 @@ Record grouping configuration --- -## HttpMethod +## HttpMethodSubset + +HTTP methods a view data source may request — the subset of `HttpMethod` without `HEAD`/`OPTIONS`. ### Allowed Values diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 5fe9264c79..1ac7080c2b 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3314,8 +3314,8 @@ "GroupingConfig (type)", "GroupingConfigSchema (const)", "GroupingFieldSchema (const)", - "HttpMethodSchema (const)", - "HttpMethodType (type)", + "HttpMethodSubset (type)", + "HttpMethodSubsetSchema (const)", "HttpRequest (type)", "HttpRequestSchema (const)", "I18nLabel (type)", @@ -4346,8 +4346,8 @@ "FlowName (type)", "FlowNameSchema (const)", "HttpMethod (type)", - "HttpMethodSchema (const)", - "HttpMethodType (type)", + "HttpMethodSubset (type)", + "HttpMethodSubsetSchema (const)", "HttpRequest (type)", "HttpRequestSchema (const)", "IsolationLevel (type)", diff --git a/packages/spec/docs-import-surface.baseline.json b/packages/spec/docs-import-surface.baseline.json index c4296629ad..64a2e49bd4 100644 --- a/packages/spec/docs-import-surface.baseline.json +++ b/packages/spec/docs-import-surface.baseline.json @@ -118,7 +118,6 @@ "ui/GanttConfig \u2014 no type export", "ui/GanttQuickFilter \u2014 no type export", "ui/GroupingField \u2014 no type export", - "ui/HttpMethod \u2014 no type export", "ui/KanbanConfig \u2014 no type export", "ui/NavigationMode \u2014 no type export", "ui/ObjectListView \u2014 no type export", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index df7f1f6224..5dc31c247f 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1108,6 +1108,7 @@ "shared/FieldName", "shared/FlowName", "shared/HttpMethod", + "shared/HttpMethodSubset", "shared/HttpRequest", "shared/IsolationLevelEnum", "shared/MetadataFormat", @@ -1526,7 +1527,7 @@ "ui/GroupNavItem", "ui/GroupingConfig", "ui/GroupingField", - "ui/HttpMethod", + "ui/HttpMethodSubset", "ui/HttpRequest", "ui/I18nLabel", "ui/I18nObject", diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 8f1347c72f..f22a81dffb 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -10,6 +10,11 @@ import path from 'path'; import { spawnSync } from 'child_process'; import { z } from 'zod'; import { schemaNameFromExportKey } from './lib/schema-name'; +import { + findDefKeyCollisions, + formatDefKeyCollisions, + type EmittedDef, +} from './lib/def-key-collisions'; import { RENAMED_DEFS, carryAuthorableKey, checkRenameTable } from './lib/renamed-defs'; import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry'; import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../src/migrations/registry'; @@ -296,6 +301,12 @@ const generatedSchemas = new Map>(); // roots instead of approximating reachability from names or imports. const zodByDefKey = new Map(); +// Every export this run published, in encounter order, so the def-key collision +// guard below can see the writes `generatedSchemas` collapses. That map is +// keyed by def key and `set()` is unconditional, so by the time a duplicate is +// in it the loser is already gone — the record has to be kept alongside (#5832). +const emittedDefs: EmittedDef[] = []; + // Error messages for schema types that inherently cannot be represented in JSON Schema. // These are expected warnings, not build-breaking errors. const KNOWN_UNSUPPORTED_PATTERNS = [ @@ -373,6 +384,7 @@ for (const [namespaceName, namespaceExports] of Object.entries(Protocol)) { writeFileWithRetry(filePath, JSON.stringify(jsonSchema, null, 2)); generatedSchemas.set(`${categorySlug}/${schemaName}`, jsonSchema); zodByDefKey.set(`${categorySlug}/${schemaName}`, value); + emittedDefs.push({ category: categorySlug, exportKey: key, schemaName, schema: value }); console.log(` ✓ ${namespaceName.toLowerCase()}/${fileName}${io === 'input' ? ' (input shape)' : ''}`); count++; if (io === 'input') inputModeCount++; @@ -407,6 +419,21 @@ if (errorCount > 0) { process.exit(1); } +// ─── Guard: one def key, one schema (#5832) ────────────────────────── +// `generatedSchemas.set()` above is an unconditional overwrite, so two exports +// of one namespace that strip to the same schema name publish ONE file and the +// loser vanishes without a word — which is how `shared/HttpMethod` shipped the +// five-value view subset while `api/*` routes were declared with the seven-value +// enum of the same name. Runs BEFORE both ratchets: neither can see this (they +// measure def keys, and a collision produces exactly one), and neither should +// adjudicate a build whose output already depends on export iteration order. +// See lib/def-key-collisions.ts for why a self-alias is exempt. +const defKeyCollisions = findDefKeyCollisions(emittedDefs); +if (defKeyCollisions.length > 0) { + console.error(`\n❌ ${formatDefKeyCollisions(defKeyCollisions)}`); + process.exit(1); +} + // ─── Ratchet: a published schema must never silently disappear ──────── // json-schema/ is a public contract surface (IDE validation, gen:docs input, // $id URLs under schema.objectstack.io). The manifest is the committed record diff --git a/packages/spec/scripts/def-key-collisions.test.ts b/packages/spec/scripts/def-key-collisions.test.ts new file mode 100644 index 0000000000..0b8afb92bd --- /dev/null +++ b/packages/spec/scripts/def-key-collisions.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins the def-key collision guard (#5832). + * + * `build-schemas.ts` is a top-level script with side effects, so the guard is + * extracted for the same reason `schema-index` (#4696), `format-type` (#4912) + * and `schema-name` (#4592) were: the only other way to assert on it is to run + * the whole generator and read what it wrote — and "what it wrote" is precisely + * the evidence a last-writer-wins overwrite destroys. + * + * Three facts carry the fix, one test each: + * + * 1. two DIFFERENT schemas under one def key is an ERROR, never an overwrite — + * the `shared/HttpMethod` shape, reproduced literally; + * 2. a SELF-ALIAS (`export const ThemeMode = ThemeModeSchema`) is not a + * collision: one object, one artifact, nothing order-dependent; + * 3. identity is the test, not today's byte-equality — two independent + * declarations that happen to serialize alike are still a collision, + * because the next edit to either makes the artifact order-dependent again. + * + * Plus the boundary the whole guard rests on: the def key is per CATEGORY, so + * the same name in two namespaces is two published schemas and must stay legal. + */ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + findDefKeyCollisions, + formatDefKeyCollisions, + type EmittedDef, +} from './lib/def-key-collisions'; +import { schemaNameFromExportKey } from './lib/schema-name'; + +/** An entry as `build-schemas.ts` builds it — schema name via the real strip. */ +const emitted = (category: string, exportKey: string, schema: unknown): EmittedDef => ({ + category, + exportKey, + schemaName: schemaNameFromExportKey(exportKey), + schema, +}); + +describe('findDefKeyCollisions', () => { + it('reports two DIFFERENT schemas under one def key — the #5832 `shared/HttpMethod` shape', () => { + const sevenValues = { enum: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] }; + const fiveValues = { enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] }; + + expect(findDefKeyCollisions([ + emitted('shared', 'HttpMethod', sevenValues), + emitted('shared', 'HttpMethodSchema', fiveValues), + ])).toEqual([ + { defKey: 'shared/HttpMethod', exportKeys: ['HttpMethod', 'HttpMethodSchema'] }, + ]); + }); + + it('allows a SELF-ALIAS — `export const ThemeMode = ThemeModeSchema` is one object twice', () => { + const themeMode = { enum: ['light', 'dark', 'auto'] }; + + expect(findDefKeyCollisions([ + emitted('ui', 'ThemeModeSchema', themeMode), + emitted('ui', 'ThemeMode', themeMode), + ])).toEqual([]); + }); + + it('still reports two declarations that merely SERIALIZE alike — identity is the test', () => { + // Structurally equal, referentially distinct: two sources of truth for one + // published name. Today's artifact is the same either way, which is exactly + // why nothing else would ever report it. + const collisions = findDefKeyCollisions([ + emitted('system', 'RetryPolicySchema', { type: 'object' }), + emitted('system', 'RetryPolicy', { type: 'object' }), + ]); + + expect(collisions).toEqual([ + { defKey: 'system/RetryPolicy', exportKeys: ['RetryPolicySchema', 'RetryPolicy'] }, + ]); + }); + + it('keys by CATEGORY: one name published by two namespaces is two schemas, not a collision', () => { + expect(findDefKeyCollisions([ + emitted('api', 'ServiceStatusSchema', { enum: ['up'] }), + emitted('system', 'ServiceStatusSchema', { type: 'object' }), + ])).toEqual([]); + }); + + it('reports every colliding key, in first-encounter order, and only the colliding ones', () => { + const shared = {}; + const collisions = findDefKeyCollisions([ + emitted('ui', 'ThemeModeSchema', shared), + emitted('ui', 'ThemeMode', shared), + emitted('shared', 'HttpMethod', { a: 1 }), + emitted('shared', 'HttpMethodSchema', { a: 2 }), + emitted('data', 'FieldSchema', {}), + emitted('api', 'EndpointSchema', { b: 1 }), + emitted('api', 'Endpoint', { b: 2 }), + ]); + + expect(collisions.map((c) => c.defKey)).toEqual(['shared/HttpMethod', 'api/Endpoint']); + }); + + it('is silent on a build with no collisions', () => { + expect(findDefKeyCollisions([ + emitted('shared', 'HttpMethod', { a: 1 }), + emitted('shared', 'HttpMethodSubsetSchema', { a: 2 }), + ])).toEqual([]); + }); +}); + +describe('formatDefKeyCollisions', () => { + it('names the file that would be written, both export keys, and the source-side remedies', () => { + const message = formatDefKeyCollisions([ + { defKey: 'shared/HttpMethod', exportKeys: ['HttpMethod', 'HttpMethodSchema'] }, + ]); + + expect(message).toContain('json-schema/shared/HttpMethod.json'); + expect(message).toContain('HttpMethod, HttpMethodSchema'); + // The remedy must be the rename precedent, not "regenerate and move on". + expect(message).toContain('#4684'); + expect(message).toContain('renamed-defs.ts'); + expect(message).toContain('#5832'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// The real generator, as a subprocess — the REVERSE verification. +// ───────────────────────────────────────────────────────────────────────── +// +// The green side of this guard has plenty of witnesses: every `gen:schema`, +// every `check:authorable-surface`, and `build-schemas-check-mode.test.ts`'s +// whole suite run the unmutated script. The RED side has none — and a gate +// that has never been observed failing is not known to be a gate (#5168's +// finding, applied here). So this one case puts the deleted limb back: it +// re-declares `HttpMethodSchema` alongside the renamed `HttpMethodSubsetSchema` +// in a COPY of `src/`, which is the #5832 defect exactly (`HttpMethod` and +// `HttpMethodSchema` both strip to `shared/HttpMethod`), and asserts the build +// now stops. Predicted direction, written before it was run: RED, because the +// two exports are different Zod instances — not a self-alias. +// +// It re-adds the old name rather than renaming the new one back, because a +// rename would break `ui/view.zod.ts`'s import and the script would die during +// module load — a red for the wrong reason, which proves nothing about the +// guard. The assertions below therefore also check the run reached the +// generation summary first. +// +// Sandbox discipline copied from `build-schemas-check-mode.test.ts` and +// `openapi-self-consistency.test.ts`: the script resolves `OUT_DIR` and every +// artifact path from its own `__dirname`, so running a mutated copy in place +// would rewrite the package's real (gitignored) `json-schema/` while a +// concurrent `pnpm --filter @objectstack/spec build` is writing it. `src/` is +// COPIED here rather than symlinked because the mutation is in `src/` — a +// symlinked directory resolves its relative imports against the real path, so +// `ui/view.zod.ts` would keep reading the unmutated `shared/http.zod.ts`. +const PKG = path.resolve(__dirname, '..'); + +/** One full spec surface (~1600 schemas) per run; a timeout must mean "hung". */ +const SPAWN_TIMEOUT_MS = 180_000; + +let sandbox: string; + +beforeAll(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'os-defkey-5832-')); +}); +afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); +}); + +describe('build-schemas.ts refuses a second write of one def key (#5832)', () => { + it( + 'goes red when `HttpMethodSchema` is re-declared next to the 7-value `HttpMethod`', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + const dir = path.join(sandbox, 'restored-limb'); + fs.mkdirSync(dir); + fs.cpSync(path.join(PKG, 'scripts'), path.join(dir, 'scripts'), { recursive: true }); + fs.cpSync(path.join(PKG, 'src'), path.join(dir, 'src'), { recursive: true }); + for (const entry of ['node_modules', 'package.json']) { + fs.symlinkSync(path.join(PKG, entry), path.join(dir, entry)); + } + + const httpZod = path.join(dir, 'src', 'shared', 'http.zod.ts'); + const original = fs.readFileSync(httpZod, 'utf-8'); + const anchor = 'export type HttpMethodSubset = z.infer;'; + expect(original, 'anchor line must exist — the mutation is pointless otherwise') + .toContain(anchor); + const mutated = original.replace( + anchor, + `${anchor}\n` + + `export const HttpMethodSchema = lazySchema(() => z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']));\n`, + ); + expect(mutated, 'mutation must actually change the source').not.toBe(original); + fs.writeFileSync(httpZod, mutated); + + const res = spawnSync('npx', ['tsx', path.join(dir, 'scripts', 'build-schemas.ts')], { + cwd: dir, + encoding: 'utf-8', + timeout: SPAWN_TIMEOUT_MS, + env: { ...process.env, OS_EAGER_SCHEMAS: '1', NODE_OPTIONS: '--max-old-space-size=4096' }, + }); + const output = `${res.stdout ?? ''}${res.stderr ?? ''}`; + + // It got past module load and generation — so the failure below is the + // guard's verdict, not a broken fixture. + expect(output).toContain('─── Summary ───'); + + expect(res.status).toBe(1); + expect(output).toContain('JSON Schema def key(s) are claimed by two or more different schemas'); + expect(output).toContain('json-schema/shared/HttpMethod.json <- HttpMethod, HttpMethodSchema'); + // Exactly one collision: the fourteen `export const X = XSchema` self-aliases + // this package really does carry must stay green, or the guard is unusable. + expect(output).toContain('1 JSON Schema def key(s) are claimed'); + // It stops BEFORE the ratchets, which would otherwise adjudicate a build + // whose output already depends on export iteration order. + expect(output).not.toContain('json-schema.manifest.json'); + }, + ); +}); diff --git a/packages/spec/scripts/lib/def-key-collisions.ts b/packages/spec/scripts/lib/def-key-collisions.ts new file mode 100644 index 0000000000..57f49990a6 --- /dev/null +++ b/packages/spec/scripts/lib/def-key-collisions.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Two exports of one namespace publishing under the SAME def key (#5832). + * + * ## The gap this closes + * + * `build-schemas.ts` walks each namespace's runtime exports and writes + * `json-schema//.json`, where `` is + * `schemaNameFromExportKey(exportKey)` — the export key with a trailing + * `Schema` stripped. That map is many-to-one by construction: `Foo` and + * `FooSchema` both resolve to `Foo`. The write was unconditional + * (`generatedSchemas.set(defKey, …)`), so when two exports resolved to one def + * key the second silently overwrote the first, and which one survived was + * decided by export iteration order. + * + * `shared/http.zod.ts` had exactly that: `HttpMethod` (seven methods, the enum + * every `api/*` route is declared with) and `HttpMethodSchema` (five, the view + * data-source subset). Both published as `shared/HttpMethod`, the five-value + * one landed last, and so `json-schema/shared/HttpMethod.json`, the bundled + * `$defs['shared/HttpMethod']` and `references/shared/http#httpmethod` all + * described the subset — telling every downstream validator, IDE completion and + * AI metadata author that `HEAD` and `OPTIONS` are illegal on routes that + * accept them. AGENTS.md: machine-readable surfaces must not lie. + * + * Nothing reported it. `check:dual-source-exports` compares EXPORT NAMES + * (`HttpMethod` and `HttpMethodSchema` are two names, so it sees nothing) and + * the #4696 docs-index conflict check keys `/` by FILE, so two + * colliding exports of the same file are one entry to it. The collision only + * exists after the suffix strip — the same blind spot family as #4592. + * + * ## The rule + * + * > A def key may be written twice ONLY when both export keys name the very + * > same schema instance. + * + * Identity, not today's byte-equality. The permitted shape is the package's + * self-alias convention — `export const ThemeMode = ThemeModeSchema`, and + * fourteen more across `api`, `system` and `ui` — where the second write is the + * same object and therefore cannot change what is published. Anything else is + * two independent declarations under one published name: even if their JSON + * happens to match today, the next edit to either one makes the artifact + * depend on export order again, silently. So the guard refuses at the point + * where the ambiguity is introduced rather than where it becomes visible. + * + * The remedy is always at the source, never here: rename the loser to a def + * name of its own (#4684's `RateLimitConfig` precedent, ADR-0112 D9 — one name + * means one thing), or delete the duplicate and re-export the survivor. + */ + +/** One export as `build-schemas.ts` met it, before anything is written. */ +export interface EmittedDef { + /** Lowercased namespace slug — the `json-schema//` folder. */ + category: string; + /** The runtime export key (`HttpMethodSchema`). */ + exportKey: string; + /** `schemaNameFromExportKey(exportKey)` — the published name. */ + schemaName: string; + /** + * The schema object itself. Compared by REFERENCE only; this module never + * inspects it, so callers may pass the Zod instance as-is. + */ + schema: unknown; +} + +/** A def key claimed by two or more distinct schema instances. */ +export interface DefKeyCollision { + /** `/` — the file `build-schemas.ts` would write. */ + defKey: string; + /** Every export key that resolves to it, in encounter order. */ + exportKeys: string[]; +} + +/** + * Def keys written more than once by DIFFERENT schema instances. + * + * Self-aliases (every entry for a key is the identical object) are not + * collisions and are not reported. Result order follows first encounter, so a + * build's report is stable across runs. + */ +export function findDefKeyCollisions(entries: Iterable): DefKeyCollision[] { + const byDefKey = new Map(); + for (const entry of entries) { + const defKey = `${entry.category}/${entry.schemaName}`; + const bucket = byDefKey.get(defKey); + if (bucket) bucket.push(entry); + else byDefKey.set(defKey, [entry]); + } + + const collisions: DefKeyCollision[] = []; + for (const [defKey, bucket] of byDefKey) { + if (bucket.length < 2) continue; + // One object reached by two names publishes one artifact — no ambiguity. + if (bucket.every((e) => e.schema === bucket[0].schema)) continue; + collisions.push({ defKey, exportKeys: bucket.map((e) => e.exportKey) }); + } + return collisions; +} + +/** The build-stopping message for `findDefKeyCollisions()`. */ +export function formatDefKeyCollisions(collisions: readonly DefKeyCollision[]): string { + const lines = collisions.map( + (c) => ` json-schema/${c.defKey}.json <- ${c.exportKeys.join(', ')}`, + ); + return ( + `${collisions.length} JSON Schema def key(s) are claimed by two or more different schemas:\n\n` + + `${lines.join('\n')}\n\n` + + `A def key is \`/\` with \`\` = the export key minus a trailing\n` + + `\`Schema\` (scripts/lib/schema-name.ts), so \`Foo\` and \`FooSchema\` publish to ONE file.\n` + + `Writing it twice means the published artifact — json-schema/, the bundled objectstack.json\n` + + `\`$defs\`, and the reference page built from them — describes whichever export the namespace\n` + + `happened to enumerate last, and the other one is not published at all (#5832).\n\n` + + `Fix it at the source, not here:\n` + + ` - two DIFFERENT schemas: rename one to a def name of its own — that is what #4684 did for\n` + + ` \`RateLimitConfig\` and #5832 for \`HttpMethodSubsetSchema\`, and what ADR-0112 D9 means by\n` + + ` one name meaning one thing. Record the rename in scripts/lib/renamed-defs.ts when the OLD\n` + + ` def key stops being emitted;\n` + + ` - a duplicate DECLARATION of one schema: delete it and re-export the survivor, so both\n` + + ` names resolve to a single object (\`export const ThemeMode = ThemeModeSchema\` — that\n` + + ` shape is allowed here precisely because it cannot change what is published).\n` + ); +} diff --git a/packages/spec/scripts/lib/renamed-defs.ts b/packages/spec/scripts/lib/renamed-defs.ts index b59daaf987..50bb63a616 100644 --- a/packages/spec/scripts/lib/renamed-defs.ts +++ b/packages/spec/scripts/lib/renamed-defs.ts @@ -118,6 +118,25 @@ export const RENAMED_DEFS: Readonly> = { // and is deliberately absent from this table — it is neither source nor // target, and it is emitted byte-for-byte unchanged by this build. 'kernel/PackageDependency': 'kernel/ResolvedPackageDependency', // 4 keys carried + + // #5832 / ADR-0112 D9 — `shared/http.zod.ts` declared TWO different enums + // whose def keys collided after the `Schema` suffix strip: `HttpMethod` + // (7 values, the routing contract every `api/*` endpoint is declared with) + // and `HttpMethodSchema` (5 values, the view data-source subset). The subset + // won by iteration order, so `shared/HttpMethod` published five values and + // the routing contract was not published at all. The SUBSET is renamed + // (`HttpMethodSubsetSchema` / `HttpMethodSubset`) and `HttpMethod` keeps the + // bare name, per #4684's `RateLimitConfig` precedent. + // + // Only the `ui` side is a def RENAME and belongs here: `./ui` re-exported the + // subset alone, so `ui/HttpMethod` really did leave and `ui/HttpMethodSubset` + // took its keys (0-key carry: enum def, no authorable properties). + // `shared/HttpMethod` is deliberately absent — it is still emitted, by the + // 7-value enum that always declared it, so the table would reject it as a + // copy. What changed there is the def's CONTENT (5 values -> the 7 the source + // always had), which no ratchet here measures and which the changeset states. + // `shared/HttpMethodSubset` is a plain manifest addition. + 'ui/HttpMethod': 'ui/HttpMethodSubset', }; /** diff --git a/packages/spec/src/shared/http.test.ts b/packages/spec/src/shared/http.test.ts index d811fe8fba..1c9094b775 100644 --- a/packages/spec/src/shared/http.test.ts +++ b/packages/spec/src/shared/http.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { HttpMethod, - HttpMethodSchema, + HttpMethodSubsetSchema, HttpRequestSchema, CorsConfigSchema, RateLimitConfigSchema, @@ -138,19 +138,20 @@ describe('StaticMountSchema', () => { }); // ============================================================================ -// Issue #8: HttpMethodSchema and HttpRequestSchema migrated to shared +// Issue #8: the view data-source subset and HttpRequestSchema migrated to shared +// (renamed HttpMethodSchema -> HttpMethodSubsetSchema at #5832) // ============================================================================ -describe('HttpMethodSchema (migrated from view.zod)', () => { +describe('HttpMethodSubsetSchema (migrated from view.zod)', () => { it('should accept common HTTP methods', () => { const valid = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; valid.forEach(m => { - expect(HttpMethodSchema.parse(m)).toBe(m); + expect(HttpMethodSubsetSchema.parse(m)).toBe(m); }); }); it('should reject HEAD and OPTIONS (subset for UI data sources)', () => { - expect(() => HttpMethodSchema.parse('HEAD')).toThrow(); - expect(() => HttpMethodSchema.parse('OPTIONS')).toThrow(); + expect(() => HttpMethodSubsetSchema.parse('HEAD')).toThrow(); + expect(() => HttpMethodSubsetSchema.parse('OPTIONS')).toThrow(); }); }); diff --git a/packages/spec/src/shared/http.zod.ts b/packages/spec/src/shared/http.zod.ts index 8c64374c10..b9d5ea775e 100644 --- a/packages/spec/src/shared/http.zod.ts +++ b/packages/spec/src/shared/http.zod.ts @@ -14,29 +14,57 @@ import { z } from 'zod'; // ========================================== /** - * HTTP Method Enum + * HTTP Method Enum — the full method vocabulary of the routing contract. + * + * This is the one `api/*` routes are declared with (`ApiEndpointSchema.method`, + * `RestServerConfig` routes, `plugin-rest-api.zod.ts`, `RouterConfig`), so it is + * the online contract and it keeps the bare published name `shared/HttpMethod`. + * + * [#5832] It shares this file with a NARROWER five-method enum, and until #5832 + * the two published under the same def key: `schemaNameFromExportKey` strips the + * `Schema` suffix, so `HttpMethod` and `HttpMethodSchema` both resolved to + * `shared/HttpMethod` and `build-schemas.ts` wrote the second over the first. + * `json-schema/shared/HttpMethod.json`, the bundled `$defs['shared/HttpMethod']` + * and `references/shared/http#httpmethod` therefore described the FIVE-value + * subset, so every downstream that validates against the published schema — + * IDE completion, codegen, an AI metadata author — was told `HEAD` and + * `OPTIONS` are illegal on routes that accept them. The subset now publishes + * under its own name (`HttpMethodSubset`), and `findDefKeyCollisions()` in + * `scripts/lib/def-key-collisions.ts` fails the build on the next collision of + * this shape. */ import { lazySchema } from './lazy-schema'; export const HttpMethod = z.enum([ - 'GET', - 'POST', - 'PUT', - 'DELETE', - 'PATCH', - 'HEAD', + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'PATCH', + 'HEAD', 'OPTIONS' -]); +]).describe('HTTP method — the full routing vocabulary (`api/*` endpoints, router and REST-server routes). The narrower `HttpMethodSubset` is what view data sources may request.'); export type HttpMethod = z.infer; /** - * HTTP Method Schema (subset for UI/View data sources) - * Common HTTP methods used in view data source configurations. - * Migrated from ui/view.zod.ts to shared for reuse across modules. + * HTTP Method Subset — the five methods a VIEW DATA SOURCE may request. + * + * A strict subset of `HttpMethod`: no `HEAD`, no `OPTIONS`. Consumed by + * `HttpRequestSchema.method` (and through it by `ui/view.zod.ts`'s API data + * sources), which is the only place the narrowing is enforced. + * + * [#5832] Named `HttpMethodSchema` until #5832, which published it under + * `shared/HttpMethod` — the SAME def key as the seven-value enum above, last + * writer winning. The rename follows #4684's `RateLimitConfig` precedent and + * ADR-0112 D9's rule that one name means one thing; the type alias moved with + * it (`HttpMethodType` -> `HttpMethodSubset`) so the published def, the schema + * const and the type alias are the one name this package's `Schema` / + * `` convention (and `lib/docs-import-surface.ts`) require them to be. */ -export const HttpMethodSchema = lazySchema(() => z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])); +export const HttpMethodSubsetSchema = lazySchema(() => z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) + .describe('HTTP methods a view data source may request — the subset of `HttpMethod` without `HEAD`/`OPTIONS`.')); -export type HttpMethodType = z.infer; +export type HttpMethodSubset = z.infer; /** * HTTP Request Configuration Schema @@ -45,7 +73,7 @@ export type HttpMethodType = z.infer; */ export const HttpRequestSchema = lazySchema(() => z.object({ url: z.string().describe('API endpoint URL'), - method: HttpMethodSchema.optional().default('GET').describe('HTTP method'), + method: HttpMethodSubsetSchema.optional().default('GET').describe('HTTP method'), headers: z.record(z.string(), z.string()).optional().describe('Custom HTTP headers'), params: z.record(z.string(), z.unknown()).optional().describe('Query parameters'), body: z.unknown().optional().describe('Request body for POST/PUT/PATCH'), diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 90e8f4593a..3aace18a05 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -14,7 +14,7 @@ import { PaginationConfigSchema, ViewDataSchema, HttpRequestSchema, - HttpMethodSchema, + HttpMethodSubsetSchema, ColumnSummarySchema, RowHeightSchema, GroupingConfigSchema, @@ -40,17 +40,17 @@ import { defineView, } from './view.zod'; -describe('HttpMethodSchema', () => { +describe('HttpMethodSubsetSchema', () => { it('should accept valid HTTP methods', () => { const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; methods.forEach(method => { - expect(() => HttpMethodSchema.parse(method)).not.toThrow(); + expect(() => HttpMethodSubsetSchema.parse(method)).not.toThrow(); }); }); it('should reject invalid HTTP methods', () => { - expect(() => HttpMethodSchema.parse('INVALID')).toThrow(); + expect(() => HttpMethodSubsetSchema.parse('INVALID')).toThrow(); }); }); @@ -2639,14 +2639,14 @@ describe('ListViewSchema — retired responsive/performance (#3896 close-out)', }); }); -describe('HttpMethodSchema/HttpRequestSchema backward compat', () => { +describe('HttpMethodSubsetSchema/HttpRequestSchema backward compat', () => { it('should still be importable from view.zod', () => { - expect(HttpMethodSchema).toBeDefined(); + expect(HttpMethodSubsetSchema).toBeDefined(); expect(HttpRequestSchema).toBeDefined(); }); it('should still parse correctly when imported from view.zod', () => { - expect(HttpMethodSchema.parse('GET')).toBe('GET'); + expect(HttpMethodSubsetSchema.parse('GET')).toBe('GET'); const result = HttpRequestSchema.parse({ url: '/api/test' }); expect(result.method).toBe('GET'); }); @@ -2757,13 +2757,22 @@ describe('[#4688] HttpRequest is single-source across ./shared and ./ui', () => // 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) +// shared/http.zod.ts `HttpMethodSubsetSchema`/`HttpMethodSubset` → 5 (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. // +// [#5832] The 5-value side was spelled `HttpMethodSchema`/`HttpMethodType` until +// #5832. That const still collided with the 7-value enum one layer down — after +// `schemaNameFromExportKey` strips the `Schema` suffix both published as +// `shared/HttpMethod`, and the subset overwrote the routing contract in +// `json-schema/`, in the bundled `$defs` and on the reference page. Renaming the +// trio to `HttpMethodSubsetSchema` / `HttpMethodSubset` / `/HttpMethodSubset` +// is what freed `shared/HttpMethod` to publish the 7 values it always declared; +// the assertions below are unchanged in substance, only in spelling. +// // 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 was a no-op until #5286 (`tsconfig.json` excluded @@ -2809,10 +2818,12 @@ describe('[#4691] `HttpMethod` is not exported from ./ui', () => { // 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 + // 2. …but it still offers the 5-value type under its own name, so the // migration stays inside this entry point. - const uiMethodType = uiExports.find((e) => e.getName() === 'HttpMethodType'); - expect(uiMethodType, './ui must export `HttpMethodType`').toBeTruthy(); + expect(uiExports.map((e) => e.getName()), '`HttpMethodType` was renamed at #5832') + .not.toContain('HttpMethodType'); + const uiMethodSubset = uiExports.find((e) => e.getName() === 'HttpMethodSubset'); + expect(uiMethodSubset, './ui must export `HttpMethodSubset`').toBeTruthy(); const originOf = (sym: import('typescript').Symbol, label: string) => { const decl = unalias(sym).declarations?.[0]; @@ -2823,7 +2834,7 @@ describe('[#4691] `HttpMethod` is not exported from ./ui', () => { }`; }; - expect(originOf(uiMethodType!, './ui HttpMethodType')) + expect(originOf(uiMethodSubset!, './ui HttpMethodSubset')) .toMatch(/^src\/shared\/http\.zod\.ts:\d+$/); // 3. `./shared` and `./api` keep naming ONE declaration `HttpMethod` — the @@ -2838,7 +2849,7 @@ describe('[#4691] `HttpMethod` is not exported from ./ui', () => { 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 () => { + it('keeps the two value ranges distinct: 7 for `HttpMethod`, 5 for `HttpMethodSubsetSchema`', async () => { const sharedEntry = await import('../shared/index'); const apiEntry = await import('../api/index'); @@ -2848,20 +2859,20 @@ describe('[#4691] `HttpMethod` is not exported from ./ui', () => { expect([...sharedEntry.HttpMethod.options].sort()).toEqual( ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'], ); - expect([...sharedEntry.HttpMethodSchema.options].sort()).toEqual( + expect([...sharedEntry.HttpMethodSubsetSchema.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'); + expect(sharedEntry.HttpMethodSubsetSchema.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.HttpMethodSubsetSchema.parse('HEAD')).toThrow(); + expect(() => uiEntry.HttpMethodSubsetSchema.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. diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index ea83ebfe05..9a38378c8b 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -18,7 +18,7 @@ import { BulkActionDefSchema } from './bulk-action.zod'; * HTTP Method Enum & HTTP Request Schema * Migrated to shared/http.zod.ts. Re-exported here for backward compatibility. */ -import { HttpMethodSchema, HttpRequestSchema } from '../shared/http.zod'; +import { HttpMethodSubsetSchema, HttpRequestSchema } from '../shared/http.zod'; import { lazySchema } from '../shared/lazy-schema'; /** @@ -34,7 +34,7 @@ const VIEW_HISTORY = 'Until #4001 closed these shapes an unknown key was dropped silently — the view still ' + 'rendered, without whatever the key was meant to configure.'; -export { HttpMethodSchema, HttpRequestSchema }; +export { HttpMethodSubsetSchema, HttpRequestSchema }; /** * [#4688] `HttpRequest` is RE-EXPORTED from its one declaration in @@ -54,8 +54,9 @@ 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`. + * [#4691, renamed at #5832] The type of `HttpMethodSubsetSchema` is + * `HttpMethodSubset`, 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 @@ -68,18 +69,24 @@ export type { HttpRequest } from '../shared/http.zod'; * 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`. + * against the 5-value subset. `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 5-value type carries a name of its own. + * + * #4691 spelled that name `HttpMethodType`, which was merely the name left + * over once `HttpMethod` was taken. #5832 renamed the whole trio to + * `HttpMethodSubsetSchema` / `HttpMethodSubset` / `/HttpMethodSubset` + * because the CONST still collided one layer down: `schemaNameFromExportKey` + * strips the `Schema` suffix, so `HttpMethodSchema` published as + * `shared/HttpMethod` and overwrote the 7-value enum's own def. * * 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 + * CORRECT one: TypeScript's "did you mean" points at `HttpMethodSubset` 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'; +export type { HttpMethodSubset } from '../shared/http.zod'; /** * View Data Source Configuration @@ -2649,7 +2656,8 @@ export type ViewData = z.infer; // 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. +// `HttpMethodSubset` at the top of this file (`HttpMethodType` until #5832), +// where the full rationale lives. export type ColumnSummary = z.infer; export type ColumnSummaryConfig = z.infer; export type ColumnPrefix = z.infer;