feat(spec,objectql): strictReadonlyWrites —— readonly 剥离的「响亮失败」出路 (#5126) - #5610
Conversation
按维护者对 #5126 的裁定(B 方案)补上 readonly 剥离的 strict 半边。 spec: - `WriteObservabilityOptions` 新增 `strictReadonlyWrites?: boolean`(进程内 TS 契约,与 `onFieldsDropped` 同处),TSDoc 写清语义、异常码与「不跨 RPC/VDE、远程调用方拿不到」这一事实; - `ERROR_CODE_LEDGER` 在 `@objectstack/objectql` 名下登记 `ERR_READONLY_FIELD_REJECTED`; - 契约测试钉住反 A 蔓延:该键不得出现在 `EngineUpdateOptionsSchema` / 插入袋 / 共享 base,且确实声明在契约上(防止守卫变空转)。 objectql: - 在既有剥离 + `onFieldsDropped` 接缝处兑现:置位且确有字段将被剥离时, 在任何驱动调用之前抛 `ReadonlyFieldRejectedError`,什么都不写; - 覆盖静态 `readonly` 与 `readonlyWhen` 两种原因,累积成单个异常; - 未置位时行为与此前完全一致(剥离 + 事件 + 提交)。 生成物:`content/docs/references/api/*` 随新错误码重新生成。 `content/docs/releases/` 未触碰。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
PM 预记( 正文「三个需要维护者过目的判断」PM 初审意见(否决窗口模式,维护者可推翻):三条均认可——① 覆盖两种剥离原因:裁定锚点是 Generated by Claude Code |
Conflict: packages/objectql/src/engine.ts — the `insert()` doc comment only, where #5610 (strictReadonlyWrites, #5126) and #5503 each rewrote the same block. Resolved by SUPERPOSITION, not by taking a side. Both incoming notes carried the same standing condition, and #5503 is what discharges it: - #3407: "if insert ever gains a silent strip, wire the listener at that strip site"; - #5126: "`strictReadonlyWrites` is inert here ... because insert strips nothing. If insert ever gains a strip, both members wire up together at that site." #5503 gives insert a strip (caller-supplied runtime-owned `autonumber`), so both members of `WriteObservabilityOptions` now discharge at that site: - default → strip, commit, report via `onFieldsDropped` (unchanged); - strict ON → `ReadonlyFieldRejectedError` before any driver call, and the listener deliberately does NOT fire (a refused write did not complete — #5126's own design point, mirrored). UPDATE needed no new code: the autonumber limb rides `stripReadonlyFields` → `reportDroppedFields` → `assertNoStrictDrops`, the seam #5126 already built, so a caller-supplied record number is refused at equal rank with a declared `readonly` field for free. Pinned by test rather than assumed. `ReadonlyFieldRejectedError` gained an optional `operation` (default `'update'`, so #5126's UPDATE message stays byte-identical) because the verb and the remedies genuinely differ: an INSERT refusal is always about a runtime-owned value, whose exempt writers are `isSystem` and the `preserveAudit` historical import, while `readonlyWhen` cannot lock anything on a create. No new policy was invented at the seam. #5126 states that strict "adds no second policy — it reports the existing one", and that a field the strip does not take is not rejected either; applying that verbatim is what keeps the `isSystem` / `preserveAudit` exemptions accepted under strict. Pinned in tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
Fixes #5126
按维护者两次一致的裁定(2026-08-04 拍板 + 2026-08-05 誊写)取 B 方案:
strictReadonlyWrites挂WriteObservabilityOptions(进程内 TS 契约,onFieldsDropped所在),违例抛ERR_READONLY_FIELD_REJECTED并带字段清单。A(可序列化 Zod 袋)/ C(部署级开关)/ D(objectql 私加)均未采纳。前置核实(issue 是线索,先在 origin/main 上验)
WriteObservabilityOptions+onFieldsDropped在packages/spec/src/contracts/data-engine.tsonFieldsDropped上报engine.ts的reportDroppedFields,单条与 bulk 各两处(readonly/readonly_when)packages/spec/src/api/error-code-ledger.zod.ts,SCREAMING_SNAKE + 按包归属,error-code-ledger.test.ts执行准入规则前提全部成立,未发现需要改写 issue 前提的情况。
改了什么
spec
WriteObservabilityOptions新增strictReadonlyWrites?: boolean(默认关)。TSDoc 写清三件事:语义(置位时「本会被剥离」的写入改为抛出,且什么都不写)、异常码与字段清单、以及 strict 是进程内语义——不跨 RPC / VDE,远程调用方既拿不到onFieldsDropped也拿不到 strict,其写入仍是「剥离后提交」的 200。ERROR_CODE_LEDGER在@objectstack/objectql名下登记ERR_READONLY_FIELD_REJECTED(按既有纪律:字母序、SCREAMING_SNAKE、附一行说明)。objectql
ReadonlyFieldRejectedError(readonly-strict-errors.ts,code = 'ERR_READONLY_FIELD_REJECTED',携带object/fields/drops),从 index 导出;沿用仓库既有的「以code而非instanceof跨包识别」约定。assertNoStrictDrops()。ENGINE_UPDATE_OPTION_KEYS放行该键,并同步engine-unknown-option.test.ts的 drift pin —— 不做这一步,引擎会以「未知选项」拒掉一个契约已声明的键。三个需要维护者过目的判断
1. 覆盖两种剥离原因,不只静态
readonly。 裁定原文是「被剥离时抛」,未限定原因;而它被明确锚在onFieldsDropped旁边,后者两种 reason 都发。更关键的是:静态剥离对isSystem调用方本就不生效,所以只覆盖静态一半,会让本特性对它最想服务的调用方 —— 已声明isSystem: true的可信 cron —— 近乎失效(它恰恰豁免了静态剥离,却仍在静默丢掉被readonlyWhen锁住的列)。两种原因累积进同一个异常,fields是全量并集。若维护者本意只覆盖静态readonly,这是一处需要回退的判断,改动面很小(去掉readonly_when那一路的累积)。2. strict 置位时不再回调
onFieldsDropped。DroppedFieldsEvent的契约文字是「字段被丢弃、写入在没有它们的情况下完成」;strict 下写入并未完成,照发会让监听 drop 的 flow 步骤为一次根本没发生的写入报告「部分成功」。两者是同一接缝的互斥产物,不是先后关系。3. INSERT 保持惰性。 与
onFieldsDropped同因同理:insert 对两种剥离都豁免,没有可拒绝的东西。已在 insert 处补注释说明这不是 #4371 意义上的「静默忽略」。验证
先证红。 契约测试先行,在未改 engine 的代码上跑:
实现后转绿(objectql 全量)
spec 全量:
Test Files 315 passed (315) / Tests 8027 passed (8027)反向验证(先定方向,再跑)。 两处,方向都事先写死并被证实:
EngineUpdateOptionsSchema会让 shape 检查与 smuggle 检查双双转红」→ 实测Tests 2 failed | 10 passed,两条正是预测的那两条。assertNoStrictDrops的拒绝,应让 7 条期望拒绝的用例转红,5 条默认路径用例保持绿」→ 实测恰好Failed Tests 7,全部报expected the write to be refused, but it resolved。两次改动均已还原,已确认无残留。
typecheck / 闸门
生成物与一处刻意的「不改」
新错误码进入
ErrorCode联合,故重新生成了content/docs/references/api/*(11 个文件)。逐行核对过:除error-code-ledger.mdx新增的一条条目外,其余全部只是枚举里多了ERR_READONLY_FIELD_REJECTED一个成员。packages/spec/authorable-surface.base.json被我还原了,尽管gen:schema会顺手改写它。它的改动(baseRev + 三条EmailServiceConfig键)源自别人已合入的system/email-config.zod.ts,与本 PR 无关;check:authorable-surface在还原后仍然通过,并自己说明「trails the merge base by 3 key(s) —— expected right after a surface change lands」。AGENTS.md 也明确反对无差别重生成(会把真实语义改动埋进机械 diff)。content/docs/releases/未触碰。Changeset
.changeset/strict-readonly-writes.md:@objectstack/specminor +@objectstack/objectqlminor。两者都取 minor 而非 patch,因为两侧都是新增的公开面而非修复:spec 增加了一个契约成员与一个登记错误码,objectql 增加了一项选择加入的运行时行为与一个新导出的错误类。默认行为零变化,故不是 major。🤖 Generated with Claude Code
https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D
Generated by Claude Code