feat(objectql): 把假引擎钉在 ObjectQL.delete 的真实契约上 (#4550) - #4948
Merged
Conversation
…ct (#4550) #4550 records a failure mode with four instances: a test double LOOSER than the implementation it replaces converts a green suite into no suite at all — silently, and on exactly the paths a double was introduced for, which are the paths that were hard to test, which are usually where the contract is densest. This takes ONE slice of it, the one whose criterion is mechanically decidable: the ObjectQL engine's delete dispatch. `ObjectQL.delete(object, options)` is a total function from an options bag to three verdicts — `by-id` (scalar `where.id`), `multi` (`options.multi`), or a throw — so "is this double looser?" has a yes/no answer that does not require reading the test's intent. The producer's decision now lives in one place. `engine-delete-dispatch.ts` exports `resolveEngineDeleteDispatch` / `assertEngineDeleteDispatch` / `scalarDeleteId` / `ENGINE_DELETE_DISPATCH_CASES`, `ObjectQL.delete` itself reads it, and a fake engine calls it instead of mirroring it. #4434's fix mirrored the guard by hand into one fake; a mirror is a second copy of the contract and drifts the moment either side is edited — and the scalar test is the half a mirror drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate). A double that imports the decision cannot be looser than the decision. `scripts/check-engine-double-contract.mjs` (wired into lint.yml's ESLint job, `--self-test` first per the repo's 10 other gates) finds all 39 fake ObjectQL engines by AST — separating them from the 39 DRIVER doubles, whose `delete` takes a scalar id and is a different contract — and requires the pinned call. 9 are converted here (objectql's five sys_metadata fakes, plugin-sharing's four, including the two that hand-mirrored); the other 30 sit in a measured, shrink-only baseline that reconciles in both directions. Proof it discriminates, not just that it is green: 1. `git show ba5ff2f^:…/sharing-rule.test.ts` — the pre-#4434 fake — restored: the gate goes red naming that file and line. 2. With the fake pinned and the pre-#4434 `deleteRule` restored, the PRE-EXISTING test `deleteRule drops rule + all its grants` fails with `Delete requires an ID or options.multi=true` — the same error the running server answered 500 with. Had this gate existed, #4434 could not have shipped. Verified: objectql 111 files / 1757 tests, plugin-sharing 11 / 243, both typecheck clean, ESLint clean. @objectstack/spec ran 295/295 files green with the dispatch guard installed in every engine double, which is why its type-conformance witness is EXEMPT rather than DEBT. Refs #4550, #4434 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
xuyushun441-sys
marked this pull request as ready for review
August 3, 2026 17:05
xuyushun441-sys
enabled auto-merge
August 3, 2026 17:05
This was referenced Aug 3, 2026
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4550
先核实议题里的四个实例
议题正文的转述与仓库实情有出入,逐条核实如下(两个不在本仓,一个根本没有替身):
packages/plugins/plugin-sharing/src/sharing-rule.test.ts的makeEngine().deletepackages/objectql/src/engine.tsObjectQL.delete()(reject分支)where谓词;真实 dispatch 只接受标量where.id或options.multi,否则抛Delete requires an ID or options.multi=truedeleteRule用谓词删sys_record_share,DELETE /sharing/rules/:idOrName对每一条规则、两种寻址形式都 500,而deleteRule drops rule + all its grants一直断言成功packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts的夹具// FK enforcement is off in this harness)+ 写入不存在的 idObjectTimeline.test.tsx把./renderer换成只渲染item.title的桩件startDateField—两处实情与议题正文不一致,照实记录:
objectui,本容器里没有该仓的 checkout,本仓的闸门在结构上够不到它们;所以本仓内真正属于这一族、且判据可机械判定的,只有 #4434 一个。#4441 是同族但判据是散文。
分片依据
只做一条:假 ObjectQL 引擎的
delete,必须钉在真实 dispatch 契约上。选它的理由是判据硬。
ObjectQL.delete(object, options)是一个全函数,从 options 映射到三个判决之一:where.id是标量(string/number/bigint,非null)→by-id,走driver.delete;options.multi为真 →multi,走driver.deleteMany;于是「这个替身是不是更宽松」有一个 yes/no 的答案,不需要读懂测试的意图。#4441 的判据是「注释里写了关掉某条平台约束」—— 删掉注释就绕过,也发现不了没写注释的那些;那需要一条声明式欠债台账,不是扫描器,另立单更合适。
其余三个方法(
find的过滤语义、update的孪生 dispatch、未知选项拒绝)是同一族,但每个都得先在生产侧抽出自己的谓词。delete之所以现在就能做,是因为 #4434 已经把这笔钱付过了。做法:一个谓词,两端共用
packages/objectql/src/engine-delete-dispatch.ts导出resolveEngineDeleteDispatch/assertEngineDeleteDispatch/scalarDeleteId/ENGINE_DELETE_DISPATCH_CASES,ObjectQL.delete自己读它,替身也调它:#4434 的修复是把守卫手抄进那一个假引擎。手抄出来的是契约的第二份副本,任一侧被编辑就会漂移 —— 而且手抄最常丢的正是标量那一半:
where: { id: { $in: [...] } }看着像 id,其实是多行谓词,真实引擎在没有multi时拒绝它,而if (!opts?.where?.id && !opts?.multi)会放行。共用生产侧的函数把这一类整体消掉:调用了那个决定的替身,不可能比那个决定更宽松。与 #4455「扫描与校验器必须用同一个谓词回答问题」是同一形状。engine-delete-dispatch.test.ts不是拿谓词对着旁边写的期望表跑,而是用录制 driver 驱动真实引擎逐条跑ENGINE_DELETE_DISPATCH_CASES,断言真实引擎的观测行为等于谓词的判决。谓词一旦与engine.ts漂移,红在这里。闸门
scripts/check-engine-double-contract.mjs,接进lint.yml的 ESLint job(纯静态 AST,不需要 build),按仓内 10 个check:*的惯例--self-test && 真实检查。三条不变式:
check:i18n应把 unknown-authoring-key lint 判为失败 —— 否则第十份 extract 配置还会照抄同一个错 #4804 / check:init-service-contract 只认getService,不认getServiceAsync—— #4772 就是从这个洞里溜过去的 #4835 /merge.os-regen.driver指向「上一个装过依赖的 worktree」的绝对路径 —— 该 worktree 一删,全容器的生成物合并驱动就坏了 #4868 /.claude/skills/**的 markdown 不被任何门禁扫描 —— check:nul-bytes 只看 JS/TS,check:doc-authoring 的 ROOTS 不含 .claude/ #4890 / docs-accuracy-audit.js 的 ALL_HANDWRITTEN 清单 11 条全部指向改名前的 protocol/objectos/* —— 内部文档审计静默漏审整个 protocol/kernel 目录 #4851 那一族。delete走共享谓词,或其文件在实测基线里。范围切分是机械的,不是靠命名:假 driver 的
delete(object, id, options)第二参是主键,是另一条契约,必须不被扫进来 —— 仓内 126 个delete方法里 39 个是 driver 替身,全部正确排除。双向证明
1. 历史坏状态上判红
把 #4434 修复前的假引擎原样取回:
2. 钉住之后,缺陷本身判红
上面只证明了「缺了那一行会被点名」。更强的一步:保持替身钉住,把 #4434 修复前的
sharing-rule-service.ts取回(deleteRule用谓词删sys_record_share),跑既有的那条测试:抛出的正是当年真服务器返回 500 时的那条错误。当年若有这条闸门,#4434 进不来。
3. 当前 main 判绿,且不误伤
没有为了绕过而放宽判据。 9 个改成钉住的替身,其所在包的测试全绿(见下),没有一个是靠松开判据换来的。
未纳入范围的部分
scripts/engine-double-contract.baseline.json(只减不增,双向对账)。多数所在包不依赖@objectstack/objectql,钉住需要加 devDependency + 改 lockfile,是一次独立可评审的动作;plugin-approvals(6) 与runtime(1) 已有该依赖,是一行改动,只是不在本片里(未实测的 suite 若翻红,应当有自己的 PR)。其中 3 个(plugin-security、service-automation、service-queue)已经手抄了守卫 —— 那正是本闸门要消掉的第二份副本,台账里写明了。packages/spec/src/contracts/data-engine.test.ts是IDataEngine的类型可实现性见证,只断言typeof engine.delete === 'function',没有被测代码去驱动它;而且原理上钉不了 —— objectql 依赖 spec,反向 import 会倒置依赖。(顺带:给全部 39 个替身临时装上守卫后,@objectstack/spec295/295 个测试文件全绿,所以这条是 EXEMPT 而非 DEBT。)台账不声称的东西
基线条目记录的是「该替身结构上比契约宽松」。它不声称这份宽松当下无害 —— 逐文件证明那件事需要装上守卫跑每个 suite,本 PR 只对
@objectstack/spec整包做过(295/295 全绿)。其余是未证之状态,这也正是它们记为 DEBT 而非 EXEMPT 的原因。基线的$comment里原样写着这一点。验证
本 PR 改了根
package.json(scripts段末尾)与.github/workflows/lint.yml(ESLint job 末尾) —— 本仓最容易冲突的两处。两处都是在既有列表末尾追加一行/一个 step,没有改动任何既有条目,所以与并行 PR 冲突时解法是取并集,保留对方的改动,不要二选一。(今日已因package.json相邻行发生过一次合并冲突。)不接进
lint.yml的话没人会跑它 —— 那恰好是 #4868 那一族的成因,所以这次接入是必须的,不是可选的。变更文件
packages/objectql/src/engine-delete-dispatch.ts、engine-delete-dispatch.test.tspackages/objectql/src/engine.ts(delete()改读共享谓词)、index.ts(导出)scripts/check-engine-double-contract.mjs、scripts/engine-double-contract.baseline.jsonpackage.json、.github/workflows/lint.yml@objectstack/objectql: minor —— 新增公开导出)未触碰
content/docs/releases/。Generated by Claude Code