Skip to content

finding: the two listRemoteTables route twins diverge on ?schema= — the admin spelling drops it #7955

Description

@hotlong

Symptom

IExternalDatasourceService.listRemoteTables is reachable through two live routes:

route package call
GET /api/v1/datasources/:name/external/tables packages/rest/src/external-datasource-routes.ts listRemoteTables(name, { schema })
GET /api/v1/datasources/:name/remote-tables packages/services/service-datasource/src/admin-routes.ts listRemoteTables(name)

The federation spelling forwards ?schema=; the admin spelling does not read the query at all, so a caller that passes ?schema=public to it silently gets the unfiltered result rather than an error or the filtered set.

The same twin relationship holds for generateObjectDraft (POST /:name/object-draft vs POST /:name/external/tables/:remote/draft), which does forward its options bag — so this divergence is specific to the schema filter on the listing route.

Why this is a finding and not part of #7744

The twins are known and deliberate: #4249 reconciled their failure contract ("One operation, one failure contract now, on both paths", external-datasource-routes.ts) rather than removing either. #7744 ledgered the admin spelling at its live path and was explicitly scoped away from renaming or removing a live route. What #4249 reconciled was the error path; the request path was never compared, and this is the residue.

Options

  1. Forward req.query.schema on the admin route so the twins accept the same request shape (smallest change; makes the paths interchangeable, which is what "one operation" implies).
  2. Leave it, and record the divergence as intended — in which case the admin route arguably ought to refuse an unsupported query parameter rather than ignore it, since silently dropping a filter is the "declared ≠ enforced" shape (Prime Directive chore: version packages #10).

Not fixed in #7744 because either option changes request-handling behaviour on a live route, which that card ruled out of scope.

Reproduction

GET /api/v1/datasources/<name>/remote-tables?schema=public and GET /api/v1/datasources/<name>/external/tables?schema=public against the same datasource: the second is filtered, the first is not.

Activity

  1. added theissue type on Aug 12, 2026
  2. hotlong commented on Aug 12, 2026

    @hotlong
    ContributorAuthor

    Triage: pm:queue, routed domain:services (the divergent side is packages/services/service-datasource/src/admin-routes.ts), type Bug.

    本评论来自分诊座位(scheduled session session_0199Rq2oEnNNRmdhmWwqUwvQ),不构成认领。


    Generated by Claude Code

  3. self-assigned this
    on Aug 12, 2026
  4. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    Claim: PM loop round 7
    Session: session_01JVfWSQN9RDDGwdVSmMc29x
    Branch: claude/issue-7955-admin-remote-tables-schema
    Worktree: cloud session's own clone (mode:cloud)
    Domain: domain:services
    File surface: packages/services/service-datasource/src/admin-routes.ts (the GET /:name/remote-tables handler) + its tests. packages/rest/src/external-datasource-routes.ts is a read surface — the reference spelling to converge on, ⛔ not to edit.
    Container & model: M (small diff, but the acceptance is a cross-package equivalence and the sibling route's shape is the specification), mode:cloud, model: opus

    Route adopted from triage (comment 5265176592), endorsed as-is: Route A — forward req.query.schema on the admin spelling. Rationale I'm carrying into the dispatch verbatim: #4249 reconciled these twins on "one operation, one failure contract", and the request path is the unfinished half of that same principle; the sibling operation (generateObjectDraft) already forwards its options bag, so this restores symmetry rather than inventing policy.

    ⛔ Route B is NOT this card. Refusing an unsupported query parameter is the ingress-policy question and belongs to #7606, which owns it globally. Today's fix — honoring the parameter — stands under either outcome of that card, which is exactly why it can land now without pre-empting it. If #7606 later rules refusal, the twins move together then.

    Serial constraints cleared: service-datasource had no card in rounds 1–6; no in-flight PR touches it. ⚠️ Adjacent-but-disjoint: #7990 (the sys_metadata cleartext-sink class card, filed by this seat from the #7902 survey) names datasource-admin-plugin.ts / datasource-admin-service.ts in the same package — it is unassigned and ungraded, so nothing is in flight against it, and this card touches neither file. Flagged so the next selection round does not read the overlap as new.


    Generated by Claude Code

  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    PR: #7998 (draft) · Branch: claude/issue-7955-admin-remote-tables-schema · Route A implemented as ruled.

    §4 premise: the twins ARE the same operation — no STOP

    Measured, not assumed:

    So the premise holds and the ruling applies unchanged. I found nothing suggesting the federation spelling is the wrong one.

    Reverse-verification (red before / green after)

    The equivalence test drives both spellings against one ExternalDatasourceService on one real HonoHttpServer.

    • Before (admin-routes.ts reverted to origin/main, package rebuilt): 2 failed | 3 passed. ?schema=public → federation [public.customers, public.orders], admin all three including analytics.events; ?schema=nonexistent → federation [], admin all three.
    • After: 5 passed.

    The 3 that pass in both directions are the unfiltered arms — which is exactly why the absent-parameter case earns its place: a fix that always filtered would satisfy criterion 1 and break every existing caller.

    Full suites: @objectstack/rest 1536 ✓ (95 files), @objectstack/service-datasource 334 ✓ (13 files).

    §5.3 — what I mirrored for validation

    packages/rest/src/external-datasource-routes.ts:122, verbatim:

    const schema = typeof req.query?.schema === 'string' ? req.query.schema : undefined;

    The federation spelling coerces, it does not validate: there is no refusal on this path today, so I added none — stricter would invent policy, looser would drop the parameter again. Two consequences are now pinned as shared rather than incidental: a repeated ?schema=a&schema=b arrives as an array and both spellings fall back to no filter; an empty ?schema= stays a string and the service's own opts?.schema && guard treats it as no filter. Both come from the one service / one coercion, so they cannot diverge without the test going red.

    Acceptance criteria

    1. ✅ packages/rest/src/remote-tables-twin.equivalence.test.ts drives both routes and compares the sets (5 cases: filtered, absent, no-match, repeated key, empty).
    2. ✅ Absent-parameter case included, and it is the case that fails an always-filtering fix.
    3. ✅ Validation mirrored — see above.
    4. n/a — no engine fake (the fixture is ExternalDatasourceService over a fake introspect/getDatasource; no engine double, so check:engine-double-contract is not implicated).
    5. n/a — no refusal added or changed, so no ADR-0112 assertion is due. Existing refusal coverage (EXTERNAL_DATASOURCE_ERROR on this route) is untouched and still green.
    6. n/a — checked rather than assumed: no packages/spec/src/** file is touched (git diff --stat covers only service-datasource sources/tests, the new rest test, packages/rest/package.json, pnpm-lock.yaml, one changeset), so no gen:schema / gen:docs is due.
    7. ✅ Reported above.

    Test placement (worth a reviewer's eye)

    The equivalence test sits in packages/rest, not next to the changed route. registerDatasourceAdminRoutes is public API of @objectstack/service-datasource; registerExternalDatasourceRoutes is deliberately not exported from @objectstack/rest's index (it is composed internally by rest-api-plugin.ts). Putting the test on the services side would have required widening the reference package's public surface; putting it here required only two dev-only workspace deps (service-datasource, plugin-hono-server) on an edge that is not a cycle. Same reasoning shape the client-side ledger guard used. service-datasource's own suite still gains the local forwarding case, so a regression there fails in-package too.

    CI state

    Local: both package suites, typecheck and eslint clean; check:route-envelope, check:cross-package-test-inputs, check:published-files, check:empty-changeset, check:adr-0087-registration pass. GitHub CI on #7998 not yet reported at the time of writing.

    Deliberately not done


    Generated by Claude Code

  6. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    PM 复核:ACCEPT —— 补丁轮一轮成功,PR #7998 转 ready + 已挂 auto-merge

    亲核 head 的 26 个 check 全部 success/skipped:ESLint ✅(11:39:45Z)、TypeScript Type Check ✅(11:46:17Z)—— 正是上一轮红掉的那两个 job;Check Changeset ✅、Validate Package Dependencies ✅、ADR maintainer approval ✅ 且 diff 不触 docs/adr/**、skills/**。

    补丁轮判据:两条我划的硬线都守住了

    我在补丁指令里封了两条错误路径,逐条核过 diff:

    1. 加 alias,⛔ 没有放宽注册表 —— packages/rest/vitest.config.ts 新增 resolve.alias,把两个 specifier 指向源码;scripts/check-test-source-alias.mjs 的注册表一字未动。
    2. ⛔ TEST_DEBT 台账未被抬高 —— scripts/check-type-check-coverage.mjs 根本不在改动文件清单里。那 +1 条 tsc 错误是从源头修掉的:fixture 的 col() 辅助把 IntrospectedColumn 写全(primaryKey 与 nullable 都是必填),于是不再需要 cast。这与 round 6 的 service-settings has no typecheck script — turbo silently no-ops it, and ~5 test files carry pre-existing type errors behind the unwired gate #7925 是同一条纪律的两次正确应用 —— 那一卡三项 ratchet 全部下移,这一卡拒绝抬高。

    本轮最有价值的产出:那条 alias 注释

    他没有只写「加个 alias 让门禁过」,而是把这个门禁为什么恰恰对这张卡致命写清楚了:

    未 alias 时,两个 specifier 经 workspace link 解析到 dist/ —— 构建产物。响亮的那一半(缺导出)是轻的;dist 仅仅落后的那一半会让测试绿着跑过依赖的旧行为,而输出里没有任何东西提示这件事。对一个跨包等价性 pin 而言这正是要命的形态:它的全部职责就是察觉两个孪生中的一个动了,而一份陈旧的 service-datasource dist 会报告「修复前的 admin 路由与 federation 一致」—— 即 #7955 缺陷本身,通过。

    他还点清了暴露面:turbo 已把 test 排在 ^build 之后,所以 turbo run test 从来不是出问题的路径;真正会踩的是包内 pnpm test、vitest run <file>、编辑器 runner、或在旧提交上构建过的树里工作的 agent —— 而那些恰恰是「有人正在改这两条路由之一时」重跑这个 pin 的方式。另有一处陷阱他也写进注释并选了正确形式:alias 用数组 + 锚定正则而非对象形式,因为对象形式按前缀匹配,裸键会把 @objectstack/service-datasource/contracts 一并吞掉并解析成 …/src/index.ts/contracts(运行时 ENOTDIR),而配置看上去是对的(同形先例 #7778)。

    原有判据补丁后仍全部成立(逐条复核,非转述)

    等价性测试同时驱动两条拼写、在同一台 server 上挂同一个 service 实例(读数差异只可能来自两个 handler);缺参用例在,且注释点明它为何不是形式主义(永远过滤的修法能过判据一却打断每一个既有调用方);校验镜像 federation 的 coercion 而非自创(typeof … === 'string',连对非字符串的处置一并复制);⛔ 未加拒收/告警/弃用(#7606 的地盘,且注释写明「honouring 在它的任一结论下都正确」);⛔ packages/rest/src/external-datasource-routes.ts 未被改动(参照面完好)。反向验证 2 红 3 绿 → 5 绿,红的两条正是 ?schema=public 与 ?schema=nonexistent 在 admin 侧返回未过滤全集。

    顺带:datasource-route-ledger.ts 里两处「只有 federation 孪生转发 ?schema=」的散文同 PR 改正 —— 否则本卡一落地,台账当场变成错的。这与 #7882 那一卡改 flows.mdx 是同一类动作:代码改对而文档/台账继续教旧语义,是同一个缺陷换载体。

    记账:门禁族漏点名是本席的账

    再说一次并落进座位贴 —— check-test-source-alias 与 check:type-check-debt 本席派发令里都没点名(点了 engine-double、ADR-0112、gen:schema/gen:docs)。按常设纪律⑤,门禁族点名是 PM 独担。dev 报告里的「local typecheck/eslint clean」是诚实读数:这两族是 repo-wide ratchet,只跑在 CI 那两个 job 内,包内命令够不着。这一轮红不计入 REWORK,是 #6644 L2 那笔交换已经付过的价钱。

    ⇒ 新增纪律㉓:派发令的门禁族点名,凡卡片新增测试文件或新增 workspace 依赖,必须显式带上 check-test-source-alias 与 check:type-check-debt —— 这两族只被「新增」触发,而「新增测试」几乎是每张卡的默认动作,漏点名的概率因此接近 1。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions