diff --git a/.changeset/discovery-environment-fold-exhaustive.md b/.changeset/discovery-environment-fold-exhaustive.md new file mode 100644 index 0000000000..5c9cb5238d --- /dev/null +++ b/.changeset/discovery-environment-fold-exhaustive.md @@ -0,0 +1,51 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `preview` / `trial` 的 discovery 折叠改为显式声明,并让折叠表对 EnvironmentType 穷尽 (#6287) + +`EnvironmentTypeSchema` 有七个成员,而 `NODE_ENV_TO_DISCOVERY_ENVIRONMENT` +(`api/discovery.zod.ts`)只为其中五个写了条目。`preview` 与 `trial` +一直是靠 `resolveDiscoveryEnvironment` 末行的 `?? 'development'` +兜底落到 `development` 的 —— 不是一条被写下来的决定,而是掉出表尾的副作用。 +这张表的注释本来就写明它是给后来者读的,读表的人会以为它是全的。 + +## 行为变化(唯一一处,消费者可见) + +`resolveDiscoveryEnvironment` 对两个输入的返回值改变: + +| `NODE_ENV`(或任何 operator 提供的字符串) | 之前(兜底) | 现在(声明) | +|:---|:---|:---| +| `preview` | `development` | `sandbox` | +| `trial` | `development` | `sandbox` | + +`/discovery` 的 `environment` 字段是机器可读面,客户端读它回答「我是不是在跟生产说话」, +并据此决定要不要放宽破坏性操作的二次确认。折向 `sandbox` 的三条理由: + +1. **它们在本仓语义里是什么。** 本仓的 environment 是被开通的运行容器 —— 独立数据库、 + 规范主机名、套餐档位、按环境的 RBAC(`cloud/environment.zod.ts`)。`preview` / + `trial` 是这种东西,不是 `development` / `dev` / `test` 所描述的开发机与 CI 的一次性运行; + `sandbox` 正是这个枚举里「已开通的准生产」那一档。 +2. **姿态按收紧方向取。** `trial` 尤其装着评估中客户的真实业务数据,答 `development` + 是**低报**姿态 —— 与 #5673 / #5936 把 unset 一行翻成 `production` 所要避免的是同一类错误, + 只是低一档。两者都不是 `production`:它们按定义就不是客户的生产部署,报 `production` + 会让这个字段唯一要回答的问题朝另一个方向答错。 +3. **它保住了作者的区分。** 把环境标成 `preview` 的人手里本来就有 `development` 和 `test` + 而没有选;折到 `development` 会把这个选择携带的唯一信息抹平。 + +其余五行、unset → `production`(#5673 / #5936)、未识别拼写 → `development`(#4828) +三条规则一概未动。 + +## 漏补条目从此不编译 + +折叠表拆成两张:声明面 `Record`(七个成员,穷尽), +与 operator 便利拼写 `prod` / `dev`(不属于词表,单列以免污染穷尽标注),合并后仍是原来那张查找表。 +给 `EnvironmentTypeSchema` 加一个桶而不说它折向哪里,现在直接**编译不过**。 + +这是**编译期**而非运行期断言,因为运行期断言看不见这个缺陷:兜底与三条已声明的行都产出 +`'development'`,所以调 `resolveDiscoveryEnvironment` 得到的答案在「有条目」与「`??` 现编」 +两种情况下完全一致 —— 一条运行期穷尽测试在 #6287 报告的那个坏状态下本来就是绿的。 + +`?? 'development'` 兜底保留,职责收窄为它真正服务的那一类:既不是词表成员、也不是 +operator 简写的任意字符串(`qa`、`uat`、拼错),`NODE_ENV` 是 operator 提供的自由文本, +这一类是真实输入,把它降级到 `development` 正是「猜测不得声称 production」。 diff --git a/packages/metadata-protocol/src/discovery-schema-conformance.test.ts b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts index e5a62b3c44..687c519b74 100644 --- a/packages/metadata-protocol/src/discovery-schema-conformance.test.ts +++ b/packages/metadata-protocol/src/discovery-schema-conformance.test.ts @@ -254,7 +254,13 @@ describe('[#4828] getDiscovery() conforms to DiscoverySchema', () => { expect(DiscoverySchema.safeParse(discovery).success).toBe(true); }); - it.each(['qa', 'preview', 'nonsense'])( + // [#6287] `preview` left this list when it stopped being unrecognised: it is + // a declared `EnvironmentTypeSchema` member and now has a stated fold + // (`sandbox`), so asserting `development` for it here would assert the + // opposite of the mapper's decision. The RULE these rows pin — an unknown + // spelling degrades to `development` and never claims production — is + // unchanged; only the examples had to be ones that are genuinely unknown. + it.each(['qa', 'uat', 'nonsense'])( 'NODE_ENV=%s is an unrecognised spelling — still development, never production (#4828)', async (raw) => { process.env.NODE_ENV = raw; diff --git a/packages/runtime/src/discovery-schema-conformance.test.ts b/packages/runtime/src/discovery-schema-conformance.test.ts index 3c487e0542..f97db506f6 100644 --- a/packages/runtime/src/discovery-schema-conformance.test.ts +++ b/packages/runtime/src/discovery-schema-conformance.test.ts @@ -322,7 +322,11 @@ describe('[#4828] getDiscoveryInfo() conforms to DiscoverySchema', () => { // two different rules and #5673 deliberately moved only the first — #4828's // "never CLAIM production on a guess" is untouched, and this case is the // guard against a later simplification collapsing them back into one. - it.each(['qa', 'preview', 'uat', 'nonsense'])( + // [#6287] `preview` dropped out of this list when it gained a declared fold + // (`sandbox`) — it is an `EnvironmentTypeSchema` member, so it is no longer + // an example of a spelling this repo does not recognise. The rule and its + // remaining examples are untouched. + it.each(['qa', 'uat', 'nonsense'])( 'NODE_ENV=%s is an unrecognised spelling — still development, never production (#4828)', async (raw) => { process.env.NODE_ENV = raw; diff --git a/packages/spec/src/api/discovery.test.ts b/packages/spec/src/api/discovery.test.ts index 6c045f13fa..cb9b1e147d 100644 --- a/packages/spec/src/api/discovery.test.ts +++ b/packages/spec/src/api/discovery.test.ts @@ -19,7 +19,9 @@ import { type WellKnownCapabilities, type RouteHealthEntry, type RouteHealthReport, + type DiscoveryEnvironment, } from './discovery.zod'; +import { EnvironmentTypeSchema, type EnvironmentType } from '../cloud/environment.zod'; describe('ApiRoutesSchema', () => { it('should accept valid minimal routes', () => { @@ -1197,14 +1199,22 @@ describe('[#4828] resolveDiscoveryEnvironment (decision 4 — enum, not passthro } }); + // [#6287] `preview` was one of this fixture's three examples until the fold + // table grew a row for it. The RULE is unchanged and still pinned — an + // unrecognised spelling never claims production — but `preview` is no longer + // an example of one: it is a declared `EnvironmentTypeSchema` member with a + // stated fold, so leaving it here would have asserted the opposite of what + // the source now says, and would have kept passing only because the declared + // answer happened to equal the fallback's. Re-spelled to inputs that are + // genuinely outside both the taxonomy and the operator shorthands. it('never CLAIMS production for an unrecognized spelling (#4828)', () => { - for (const raw of ['qa', 'preview', 'nonsense']) { + for (const raw of ['qa', 'uat', 'nonsense']) { expect(resolveDiscoveryEnvironment(raw), raw).toBe('development'); } }); it('every mapped result actually satisfies the declared enum', () => { - for (const raw of ['production', 'prod', 'sandbox', 'staging', 'development', 'dev', 'test', 'qa', '']) { + for (const raw of ['production', 'prod', 'sandbox', 'staging', 'development', 'dev', 'test', 'preview', 'trial', 'qa', '']) { const parsed = DiscoverySchema.parse({ name: 'ObjectStack', version: '1.0.0', @@ -1218,3 +1228,100 @@ describe('[#4828] resolveDiscoveryEnvironment (decision 4 — enum, not passthro } }); }); + +/** + * [#6287] Every `EnvironmentType` member folds by DECLARATION, not by fallback. + * + * Before this, five of the seven members had a row in the fold table and + * `preview` / `trial` fell through `?? 'development'` — a fold nobody had + * written down, in a table whose whole purpose is to be read by the next + * author. The repair is a row each, and a type that keeps the table total. + */ +describe('[#6287] the fold table is total over EnvironmentType', () => { + /** + * The declared fold per bucket. This mirrors the source table on purpose: it + * is the RUNTIME half of the pin and it proves the *values*, so a silent + * re-aim of any row (`staging` quietly moving to `development`, say) fails + * here rather than in a consumer. + */ + const declaredFold: Record = { + production: 'production', + sandbox: 'sandbox', + development: 'development', + test: 'development', + staging: 'sandbox', + preview: 'sandbox', + trial: 'sandbox', + }; + + it('resolves every EnvironmentTypeSchema member to its declared fold', () => { + const members = EnvironmentTypeSchema.options as readonly EnvironmentType[]; + // Anti-vacuity: `.options` comes through the lazySchema Proxy, and a broken + // read would make the loop below assert nothing at all. + expect(Array.isArray(members)).toBe(true); + expect(members.length).toBeGreaterThan(0); + for (const member of members) { + expect(resolveDiscoveryEnvironment(member), member).toBe(declaredFold[member]); + } + }); + + it('folds preview and trial to sandbox — the two rows this issue added', () => { + // Both are provisioned environments (isolated database, hostname, plan + // tier, per-environment RBAC), not the developer-class runs `development` + // and `test` describe — and `trial` in particular holds an evaluating + // customer's real data, so `development` would understate the posture a + // client reads this flag to judge. + expect(resolveDiscoveryEnvironment('preview')).toBe('sandbox'); + expect(resolveDiscoveryEnvironment('trial')).toBe('sandbox'); + // Declared rows go through the same operator-input normalization as the + // rest of the table, not a second path. + expect(resolveDiscoveryEnvironment(' PREVIEW ')).toBe('sandbox'); + expect(resolveDiscoveryEnvironment('Trial')).toBe('sandbox'); + }); + + it('leaves the #5936 two-rule split standing (absence ≠ unrecognised)', () => { + // Neither new row may be read as loosening those. Absence is still the host + // declining to answer; an unrecognised spelling is still a guess. + expect(resolveDiscoveryEnvironment(undefined)).toBe('production'); + expect(resolveDiscoveryEnvironment('preview-2')).toBe('development'); + }); + + it('rejects a fold table that misses a member — the exhaustiveness gate itself', () => { + // ⚠️ This assertion is made by `tsc`, not by vitest, and that is the point. + // A RUNTIME exhaustiveness test cannot see the defect #6287 reported: the + // fallback and three declared rows all produce `'development'`, so calling + // `resolveDiscoveryEnvironment('preview')` returned an identical answer + // whether a row existed or the `??` invented one. Such a test would have + // passed on the broken state. `Record` compares the KEY + // SET, which is the actual claim, so the gate lives on the source table's + // type annotation and this is its negative control. + // + // What it does and does not cover, stated exactly: + // - It DOES fail both ways on the enum's membership. Add a bucket to + // `EnvironmentTypeSchema` and the six-key literal below is missing two, + // so the source table must grow a row before anything compiles. REMOVE + // `trial` from the enum and this literal becomes complete, the directive + // goes UNUSED, and tsc reports TS2578 — a vacuously-passing negative + // control cannot hide here. + // - It does NOT catch someone widening the source annotation itself (to + // `Record`, say). No type-level pin can: the table is not + // exported, and exporting it to satisfy a test would put a private + // lookup on this package's public surface. That edit is a deliberate, + // visible change to a line whose own comment forbids it, not the silent + // drift #6287 was about — the drift was a member that nobody had to + // touch anything to omit. + // `packages/spec`'s test layer IS type-checked (`tsconfig.test.json`, named + // in the `typecheck` script since #5286) and this file carries no entry in + // `test-typecheck-debt.json`, so the directive is live, not phantom. + // @ts-expect-error [#6287] `trial` has no fold — a partial table must not type-check. + const missingTrial: Record = { + production: 'production', + sandbox: 'sandbox', + development: 'development', + test: 'development', + staging: 'sandbox', + preview: 'sandbox', + }; + expect(Object.keys(missingTrial)).toHaveLength(6); + }); +}); diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts index 5c79d54268..6a000b6cb0 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -2,6 +2,10 @@ import { z } from 'zod'; import { HttpMethod } from '../shared/http.zod'; +// [#6287] Type-only: erased at compile time, so this carries no runtime edge +// from `api/` to `cloud/`. It is what makes the NODE_ENV fold table provably +// total over the environment taxonomy rather than total by inspection. +import type { EnvironmentType } from '../cloud/environment.zod'; /** * Service Status Enum @@ -311,17 +315,52 @@ export type DiscoveryEnvironment = z.input; * | `development`, `dev` | `development` | exact / short spelling | * | `test` | `development` | ephemeral developer-class run (vitest/CI), not a provisioned pre-production copy | * | `staging` | `sandbox` | pre-production and production-LIKE; certainly not `production`, and `sandbox` is the enum's pre-production member | + * | `preview` | `sandbox` | a PROVISIONED environment (own database, hostname, plan tier, per-environment RBAC), not a developer's machine — same class as `staging` (#6287) | + * | `trial` | `sandbox` | a provisioned environment holding an evaluating customer's real business data; developer-class would understate it (#6287) | * | unset / blank | `production` | the host declined to say; every other reader of that absence already says `production`, and of the two ways to be wrong, calling a real production deployment `development` is the dangerous one (#5673, #5936) | * | anything else | `development` | an unrecognised spelling is a GUESS, and this function never claims `production` on a guess (#4828) | * * The last two rows carry the whole safety argument, and they point opposite - * ways on purpose. An unrecognised spelling (`qa`, `preview`) degrades to + * ways on purpose. An unrecognised spelling (`qa`, `uat`) degrades to * `development`, so nothing here ever advertises `production` for an * environment it failed to recognise. **Absence is not a guess** — it is the * host declining to answer, and the conservative response to that is * `production`: `environment` is machine-readable, and a client may skip * production warnings or loosen a destructive action's confirmation on it. * + * ## `preview` and `trial` are a THIRD case — declared, not absent, not a guess (#6287) + * + * Until #6287 those two reached `development` through the `??` fallback rather + * than through a decision, so this table declared five of the seven + * `EnvironmentTypeSchema` members and let the other two fall off the end. That + * is the same shape the two rows above exist to separate: the fallback answers + * for spellings this repo has never heard of, and a first-class member of our + * OWN taxonomy is not one of those. It is neither the host declining to answer + * nor a guess — it is a bucket we ship, and where it folds is ours to state. + * + * Both fold to `sandbox`, and the reasoning is the same for each: + * + * 1. **What they are here.** In this repo's taxonomy an environment is a + * provisioned runtime container — isolated database, canonical hostname, + * plan tier, per-environment RBAC (`cloud/environment.zod.ts`). A `preview` + * or `trial` environment is that, not an ephemeral developer-class run. The + * `development` bucket is reserved for the machine-and-CI class (`development`, + * `dev`, `test`); `sandbox` is the enum's provisioned pre-production member, + * which is what these two are. + * 2. **Posture, in the tightening direction.** `trial` in particular holds an + * evaluating customer's real business data. `environment` is read to decide + * whether to soften a destructive action's confirmation, so answering + * `development` there understates the posture — the same *kind* of error the + * unset row was flipped to avoid (#5673, #5936), one bucket down. Neither is + * `production`: they are by definition not the customer's production + * deployment, and claiming otherwise would make the flag's one question + * ("am I talking to production?") answer wrongly in the other direction. + * 3. **It keeps the author's distinction.** An operator who tags an environment + * `preview` had `development` and `test` available and did not pick them. + * Folding `preview` onto `development` erases the only information that + * choice carried; folding it onto `sandbox` preserves it as far as a + * three-member enum can. + * * The unset row moved from `development` to `production` in #5673 (maintainer * ruling 2026-08-06) and moved HERE, into the shared mapper, in #5936 (ruling * 2026-08-07, direction 1). #5673 could only reach its own producer — the @@ -337,15 +376,43 @@ export type DiscoveryEnvironment = z.input; * folded that into its default (it tests with `||`). Had this treated blank as * "anything else" the two producers would have drifted again on exactly that * input — the drift this consolidation exists to end. + * + * ## The taxonomy half of this table is EXHAUSTIVE, and tsc keeps it so (#6287) + * + * The seven `EnvironmentTypeSchema` rows are grouped behind a + * `satisfies Record`: adding a bucket to + * that enum without deciding where it folds does not compile, and a key that is + * not a member does not compile either. The operator shorthands (`prod`, `dev`) + * sit outside that group precisely so the check stays writable — they are + * convenience spellings, not members. + * + * It is a COMPILE-time check rather than a runtime assertion because a runtime + * one cannot see this defect: the fallback and three of the seven rows all + * produce `'development'`, so calling `resolveDiscoveryEnvironment` returns an + * indistinguishable answer whether a row exists or the `??` invented it. A + * runtime exhaustiveness test would have passed on the exact state #6287 + * reported. `tsc` compares the KEY SET, which is the actual claim. The negative + * control for it lives in `discovery.test.ts`. */ const NODE_ENV_TO_DISCOVERY_ENVIRONMENT: Readonly> = { - production: 'production', + // The seven declared `EnvironmentTypeSchema` buckets. The `satisfies` is the + // gate described above: every member must appear, and nothing that is not a + // member may (#6287). + ...({ + production: 'production', + sandbox: 'sandbox', + development: 'development', + test: 'development', + staging: 'sandbox', + preview: 'sandbox', + trial: 'sandbox', + } satisfies Record), + + // Operator convenience spellings — deliberately OUTSIDE the block above, + // because they are not taxonomy members and folding them in would make the + // exhaustiveness check unwritable. prod: 'production', - sandbox: 'sandbox', - staging: 'sandbox', - development: 'development', dev: 'development', - test: 'development', }; /** @@ -369,6 +436,13 @@ const NODE_ENV_TO_DISCOVERY_ENVIRONMENT: Readonly`, so a new + * bucket does not compile until someone says which of the three coarse postures + * it advertises. That is on purpose: before #6287 `preview` and `trial` reached + * `development` through a `??` fallback instead of a decision, and a new bucket + * would have joined them silently — including a production-side one, which is + * the dangerous direction (#5673). */ export const EnvironmentTypeSchema = lazySchema(() => z .enum(['production', 'sandbox', 'development', 'test', 'staging', 'preview', 'trial'])