Skip to content

feat(rest): direct-mount 的 9 条路由对 RestServer 可枚举,并进入 /openapi.json (#5822) - #6303

Merged
qq9340100 merged 2 commits into
mainfrom
claude/issue-5822-direct-mount-enumerable
Aug 7, 2026
Merged

feat(rest): direct-mount 的 9 条路由对 RestServer 可枚举,并进入 /openapi.json (#5822)#6303
qq9340100 merged 2 commits into
mainfrom
claude/issue-5822-direct-mount-enumerable

Conversation

@qq9340100

Copy link
Copy Markdown
Collaborator

Fixes #5822

packages/rest 的 9 条 direct-mount 路由现在对 RestServer 可枚举,并随之进入
GET {apiPath}/openapi.json 的 built-in 段。按分诊晋级裁定走选项 1:两个 registrar
返回自己实际挂载的路由描述,由组合步骤登记给 RestServer,事实来自实际调用点。

前提重验(实读 origin/main,四项全部成立)

origin/main@f6609e6ae(开 PR 前已 rebase 到含 #6246a682670b1)逐条实读:

事实 位置 结论
台账 9 条 direct-mount 行(4 packages + 5 external,其中 8 条 disposition: 'sdk') rest-route-ledger.ts:210-225 成立
getRoutes() 只回 RouteManager rest-server.ts:8638 getRoutes() { return this.routeManager.getAll(); } 成立
两个 registrar 由插件在服务在场时直挂 IHttpServer rest-api-plugin.ts:383-418(packages 受 package 服务门控;federation 无条件) 成立
openapi built-in 段来源(#5821 形状) rest-server.ts:3294 buildBuiltinPaths(this.routeManager.getAll(), basePath) 成立

一处需要修正的细节:issue 正文说两个 registrar「分别在对应服务存在时调用」。federation
那 5 条其实是无条件注册的
(external-datasource-routes.ts 模块注释写明,服务缺席时按请求答
503),只有 packages 那 4 条受 package 服务门控。这决定了诚实性两向的正确钉法(见下)。

选项 1 的同源性:为什么不是第二真相源

每个 registrar 现在只声明一次自己的路由,声明成数据,交给
mountDirectRoutes(server, routes) —— 它逐条挂载后把同一个数组原样返回:

const routes: readonly DirectMountedRoute[] = [ /* method / path / handler / metadata */ ];
return mountDirectRoutes(server, routes);

调用方登记的那份描述,就是用来挂载的那份数组本身(direct-mount-introspection.test.ts
逐条比对返回值与 mock server 上的注册调用,连 handler 的函数标识都对齐)。因此不存在
「一份用来挂载、一份用来描述」的第二清单可以漂移 —— 这正是本单点名拒绝的形状。

组合步骤 mountAndRecordDirectRoutes(direct-mount-composition.ts)从
rest-api-plugin.ts 抽出:它是唯一知道「哪些 registrar 绕开 RouteManager、各自在什么条件下被调用」
的地方。抽出的收益不只是整洁 —— conformance 测试现在驱动同一个函数,所以将来新增第三个
绕行 registrar 会自动进入台账守卫,而不是像以前那样需要有人记得同步测试里的那份副本。

诚实性两向怎么钉的

#5821 刻意选的边界是「服务器不持有该事实就不许凭空补」。本 PR 补的是事实本身,不是猜测:

  • 挂载 ⇒ 可枚举 ⇒ 进文档:服务在场时 9 条全部出现在 getRoutes()(带
    source: 'direct-mount')与 openapi built-in 段,按台账逐行对;
  • 未挂载 ⇒ 不出现:没有 package 服务的 boot,4 条 packages.* 既没挂到 wire 上,
    也不在 getRoutes()、不在文档里;
  • 无条件挂载的那 5 条照实说:federation 路由始终挂载,所以始终枚举、始终进文档 ——
    文档说的只是「什么被挂载了」,从不声称 external-datasource 服务在场。那个判断留在每次
    请求里(503),boot 期不记录(AGENTS.md「启动期不得记录一个 boot 还能推翻的判决」)。
  • 一处刻意的不对称:registrar 挂到一半抛异常时,调用方什么都不登记,宁可少报也不多报 ——
    openapi-builtin-paths.ts 对 per-operation security 选的方向一致。

#5588 的集合关系(「文档里的每条路径都是已挂载的路由」,且两侧数量相等)在 9 条加入后重跑,
依然成立。

考古:当初为什么绕开 RouteManager(台账的欠账)

结论:没有找到技术理由,是作用域/归属的产物,不是有意的绕行。证据是结构性的,写进了
direct-mount.ts 的模块注释:

  1. 两个 registrar 都是接受 IHttpServer 的自由函数,由插件RestServer 已经注册完
    自己的路由之后组合,并且各自带服务门控;
  2. RouteManagerRestServer 的私有字段,而在 rest-api-plugin.ts
    const restServer = new RestServer(...) 原本声明在 try内部 —— 到了下面
    registrar 的调用点,连 restServer 都不在作用域里,更没有 manager 可注册;
  3. package-routes.ts 现存注释只解释了「为什么不是 POST /packages」(POST /api/v1/packages is a publish-vs-install shape collision — REST registers first and shadows the dispatcher (#3587 finding) #3610
    first-match-wins 撞车),从未提过绕开 manager;RouteManager.register 对这些路由也没有任何
    拒绝条件。

本 PR 只把那个作用域障碍去掉(把 restServer 提到 try 外),没有走选项 2 —— 选项 2 会改动
9 条路由的挂载顺序与 first-match-wins 语义(#3610 的教训就在这条线上),而选项 1 已经拿到
全部收益。

conformance 测试的两套枚举收敛为一套

rest-route-ledger.conformance.test.ts 原来对 direct-mount 行另起一套枚举(mock server
捕获注册调用),现已删除。它现在按生产方式 boot(registerRoutes() +
mountAndRecordDirectRoutes),问服务器要一次 getRoutes(),再按每行的 source 分区,
两个来源各自双向对账。两个副作用值得记一笔:

  • 现在「挂载了但没登记」会红(以前这种情况对本文件不可见);
  • 台账的 source 列仍然被逐行校验,没有因为合并而失去。

未收敛的部分,以及为什么:package-envelope.conformance.test.ts /
external-datasource-envelope.conformance.test.ts 仍用 mock server 捕获注册。它们捕获的目的是
拿到 handler 去驱动(校验响应信封),不是枚举路由面 —— 与本单要收敛的「第二套枚举」不是
同一件事,强行合并只会让它们绕远。

反向验证(先预测方向,再跑)

预测:把两处 recorder.recordDirectMountedRoutes(...) 摘掉、只保留挂载(即回到
「服务器不持有该事实」的旧世界),枚举/文档类断言应当变红;而「没有无台账的 direct-mount
路由」这类方向空集通过 —— 因为它断言的是一个变空的集合,green 是「什么都没产出」而不是
「逻辑正确」。

实测(vitest run src/direct-mount-introspection.test.ts src/rest-route-ledger.conformance.test.ts):
8 failed | 9 passed,方向与预测一致。

× ledger ↔ direct-mount > every direct-mount ledger entry is really registered by its registrar
  → direct-mount rest-route-ledger entries no registrar mounts: POST /api/v1/packages/publish,
    GET /api/v1/packages, GET /api/v1/packages/:id, DELETE /api/v1/packages/:id,
    GET /api/v1/datasources/:name/external/tables, ... : expected [ ...(9) ] to deeply equal []
× ledger ↔ direct-mount > reports them through the server, not through a parallel enumeration
  → expected +0 to be 9
× #5822 > getRoutes() reports all nine, marked as direct-mount
  → POST /api/v1/packages/publish is mounted but not enumerable
× #5822 > the openapi built-in section carries all nine, ledger row by ledger row
  → POST /api/v1/packages/publish is mounted but not documented
× #5822 > files the project-scoped package mirror in the scoped document, not the unscoped one
× #5822 > the federation routes are still there — they mount unconditionally
× #5822 > the REST plugin records what it mounts > publishes the nine when the package service is there
× #5822 > the REST plugin records what it mounts > publishes the five, and only the five, when it is not
✓ ledger ↔ direct-mount > every directly-mounted route has a ledger entry      ← 空集通过
✓ #5822 > a boot without the `package` service enumerates and documents no packages route  ← 空集通过
✓ #5588 > still publishes nothing the server does not mount                     ← 另一条不变量,应当绿

那两条空集通过的用例是如实记录、不是遗漏:它们守的是「不许有无台账的路由」,登记消失后集合变空,
断言当然还成立。正因为这类 green 靠的是「什么都没产出」,才需要旁边那条按数量对账的
reports them through the server(expected +0 to be 9)—— 它是这次收敛里唯一能抓住
「登记被摘掉」的守卫。验证后已还原并复跑全绿。

验证

pnpm --filter @objectstack/rest test        → Test Files 64 passed, Tests 875 passed(rebase 后)
pnpm lint (eslint . --no-inline-config)     → 无输出
npx eslint --no-inline-config <10 个改动文件> → 无输出,exit 0
node scripts/check-nul-bytes.mjs            → OK(5956 个文件,无裸控制字节)
check:route-envelope / error-code-casing / slot-lookup / service-providers /
  startup-registry-verdict / durability-log-level / type-check-coverage /
  published-files                           → 全部 PASS
packages/client rest-route-ledger-coverage  → 1 passed(台账消费方未受影响)

packages/rest 没有 typecheck 脚本(type-check-coverage 的 DEBT 条目),仍手工跑了
tsc --noEmit -p packages/rest/tsconfig.json:改动没有新增类型错误 —— 报出的 2 条
TS2345 在 origin/main 上同样存在(同两个调用点,行号 178/245 → 207/279),已另单记录。

影响面

getRoutes() 的返回值:服务在场时多 9 条,每条多一个 source 字段(新导出类型
MountedRoute);既有的 method / path / handler / metadata 读法不变。changeset 记为
@objectstack/rest minor —— 自省面与公开文档新增了内容,并新增了导出类型,不只是修 bug。


Generated by Claude Code

…5822)

The two registrars that bypass RouteManager (`package-routes.ts`,
`external-datasource-routes.ts`) now declare their routes once, as data, hand
that array to `mountDirectRoutes` and return it; the composition step
(`mountAndRecordDirectRoutes`, called by `rest-api-plugin.ts`) records it on
the `RestServer`. `getRoutes()` therefore answers for the whole mounted
surface — each row carrying `source: 'route-manager' | 'direct-mount'` — and
the OpenAPI built-in section #5588 / PR #5821 made a projection of that table
now covers the nine, eight of which are `disposition: 'sdk'` capabilities.

The description and the mount are the SAME array, so there is no second
source of truth. Both honesty directions are pinned: a boot without the
`package` service mounts, enumerates and documents no `packages.*` route,
while the five federation routes mount unconditionally (503 per request when
the service is absent) and are documented accordingly.

The route-ledger conformance guard's second enumeration — mock-server
registration capture — is deleted: it now drives the same composition
function production does and partitions one `getRoutes()` answer by `source`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 1:40pm

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)
  • content/docs/releases/implementation-status.mdx (via @objectstack/rest)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

`packages/rest` 的 TEST_DEBT ratchet re-measure 抓到 163 -> 178 (+15): the new
`direct-mount-introspection.test.ts` imported its siblings without the `.js`
extension, which under `moduleResolution: nodenext` does not resolve — every
imported symbol degrades to `any` and each callback over it reports TS7006.
One broken import per line, not fifteen independent type errors (AGENTS.md,
Build & Test).

Extensions added there and in `rest-route-ledger.conformance.test.ts`, plus the
two `mock.calls` callbacks retyped now that `vi.fn()` resolves properly. The
test layer measures 144, so the new file contributes zero and the ledger entry
(163) is left alone — the ratchet only refuses upward drift, and lowering it
would collide with the other PRs moving the same entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW

Copy link
Copy Markdown
Collaborator Author

CI 记录:第二个 commit(a4ae4ed)修的是 TEST_DEBT ratchet,不是逻辑

首轮 CI 里 ESLint 绿、TypeScript Type Check 红,红的是 check:type-check-debt --re-measure:

• @objectstack/rest: TEST_DEBT records 163 raw tsc error(s), `tsc --noEmit` now reports 178 (+15).

原因是新测试文件 direct-mount-introspection.test.ts 的相对导入没带 .js。在
moduleResolution: nodenext 下这类导入不解析,于是它引入的每个符号退化成 any,
其上的每个回调再各报一条 TS7006 —— 15 条不是 15 个独立类型错误,是一个没写扩展名的
导入问题(AGENTS.md「Build & Test」里点名的那个陷阱)。

修法:该文件与 rest-route-ledger.conformance.test.ts 的相对导入补 .js;两处
mock.calls 的回调在 vi.fn() 能正常解析之后改成 (args: unknown[])(原来的
([path]: [string])any[][] 不匹配)。

本地按门禁同样的方式重测(sibling tsconfig,exclude 去掉 test glob):

before(首轮 CI 的形状) 178
after                  144      ← 新文件贡献 0,还顺带清掉了 conformance 测试里既有的一批

没有下调 ledger 条目(仍是 163):ratchet 只拒绝上涨,下降是 informational
(「an improvement must not have to pay a bookkeeping toll to land」);而这条是两个账里
移动最快的条目,改它容易和并行 PR 撞车。

第二轮 CI 两个门禁 job 的真实 conclusion:ESLint=completed/success
TypeScript Type Check=completed/success;该 head 上 22 个 check 全绿(Build Docs /
Console Pin Gate 按 path filter skip)。pnpm --filter @objectstack/rest test 复跑
64 files / 875 tests 全绿。


Generated by Claude Code


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rest 的 9 条 direct-mount 路由对 RestServer 不可枚举 —— 因此进不了 /openapi.json,也进不了任何运行时自省

2 participants