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/discovery-environment-fold-exhaustive.md
Original file line number Diff line number Diff line change
@@ -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<EnvironmentType, DiscoveryEnvironment>`(七个成员,穷尽),
与 operator 便利拼写 `prod` / `dev`(不属于词表,单列以免污染穷尽标注),合并后仍是原来那张查找表。
给 `EnvironmentTypeSchema` 加一个桶而不说它折向哪里,现在直接**编译不过**。

这是**编译期**而非运行期断言,因为运行期断言看不见这个缺陷:兜底与三条已声明的行都产出
`'development'`,所以调 `resolveDiscoveryEnvironment` 得到的答案在「有条目」与「`??` 现编」
两种情况下完全一致 —— 一条运行期穷尽测试在 #6287 报告的那个坏状态下本来就是绿的。

`?? 'development'` 兜底保留,职责收窄为它真正服务的那一类:既不是词表成员、也不是
operator 简写的任意字符串(`qa`、`uat`、拼错),`NODE_ENV` 是 operator 提供的自由文本,
这一类是真实输入,把它降级到 `development` 正是「猜测不得声称 production」。
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion packages/runtime/src/discovery-schema-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
111 changes: 109 additions & 2 deletions packages/spec/src/api/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand All @@ -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<EnvironmentType, DiscoveryEnvironment> = {
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<EnvironmentType, …>` 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<string, …>`, 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<EnvironmentType, DiscoveryEnvironment> = {
production: 'production',
sandbox: 'sandbox',
development: 'development',
test: 'development',
staging: 'sandbox',
preview: 'sandbox',
};
expect(Object.keys(missingTrial)).toHaveLength(6);
});
});
86 changes: 80 additions & 6 deletions packages/spec/src/api/discovery.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -311,17 +315,52 @@ export type DiscoveryEnvironment = z.input<typeof DiscoveryEnvironmentSchema>;
* | `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
Expand All @@ -337,15 +376,43 @@ export type DiscoveryEnvironment = z.input<typeof DiscoveryEnvironmentSchema>;
* 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<EnvironmentType, DiscoveryEnvironment>`: 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<Record<string, DiscoveryEnvironment>> = {
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<EnvironmentType, DiscoveryEnvironment>),

// 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',
};

/**
Expand All @@ -369,6 +436,13 @@ const NODE_ENV_TO_DISCOVERY_ENVIRONMENT: Readonly<Record<string, DiscoveryEnviro
export function resolveDiscoveryEnvironment(raw?: string | null): DiscoveryEnvironment {
const spelling = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (spelling === '') return 'production';
// [#6287] The fallback's ONE remaining job: a spelling that is not a declared
// `EnvironmentType` member and not an operator shorthand — `qa`, `uat`, a
// typo. It no longer silently answers for members of our own taxonomy; the
// table above is total over them and `tsc` keeps it that way, so a future
// bucket cannot reach this line by being forgotten. Keep it: `NODE_ENV` is an
// arbitrary operator string, so "anything else" is a real input class, and
// degrading it to `development` is what stops a guess claiming `production`.
return NODE_ENV_TO_DISCOVERY_ENVIRONMENT[spelling] ?? 'development';
}

Expand Down
Loading
Loading