Skip to content

readonly: true 在 insert 路径上完全不生效,且 update 路径的剥离会连 hook 自己写的戳一起删掉 —— 已发布文章可落成 published_at = null #788

Description

@yinlianghui

发现于 #780(published_at 重新打戳)的实测过程。与 #780 的判据无关,是引擎写路径上 readonly 语义的两个相邻事实,越界,单独记录。#780 的修复不引入也不放大第 2 点(下有对照实测)。

事实(实测,pinned 17.0.0-rc.2,真 object schema + 真 hook + sys_fetch_previous_update 内建的复刻)

探针:真 ObjectQL.create + InMemoryDriver + bindHooksToEngine,crm_knowledge_article 真 schema(published_at 声明 readonly: true),非系统上下文 ql.createContext({ userId: 'u1' })

1. insert 路径根本不剥离 readonly 字段

insert(非系统): { status: 'published', published_at: '2024-03-01T00:00:00.000Z', … }
=> 落库 published_at = "2024-03-01T00:00:00.000Z"
=> 无 "Field 'published_at' is read-only" 警告

对照 update 路径,同一个字段同一个值:

update(非系统): { id, status: 'published', published_at: '2024-03-01T00:00:00.000Z' }
=> WARN Field 'published_at' is read-only — ignoring incoming change (#2948)

源码侧一致:stripReadonlyFields@objectstack/objectql 里只有两个调用点,都在 update 分支(单行与 multi 各一),insert 分支没有。所以 readonly: true 目前是「建后不可改」,不是「不可写」——任何有 create 权限的 API 调用方都能在建记录时给 published_atlast_reviewed_atview_counthelpful_countnot_helpful_count 这类「系统计算」字段填任意值。crm_knowledge_article.enable.apiMethodscreate,这条路今天就通。

有可能是平台的有意语义(Salesforce 式 createable / updateable 二分),那样的话缺陷就不在引擎而在本仓的预期:17 个对象上所有 readonly: true 的字段注释与文档都是按「系统维护、用户不可填」写的。哪一种都需要定夺,不该靠默认。

2. update 路径的剥离会把 hook 自己写的值一起删掉

suppliedKeys 是在 hook 运行之前从调用方 payload 上快照的:

const suppliedKeys = new Set(Object.keys(opCtx.data ?? {}));
…
await this.triggerHooks("beforeUpdate", hookContext);
…
hookContext.input.data = stripReadonlyFields(updateSchema, preRo, suppliedKeys, …);

stripReadonlyFields 对每个 readonly 且落在 suppliedKeys 里的 key 做 delete result[name] —— 删的是当前值,而当前值可能已经被 hook 覆写过。于是「调用方回传了该 readonly 字段」这一件事,会连带把 hook 在同一次写里打的戳一起抹掉:

draft -> published,非系统 update,payload 里带 published_at:
=> 落库 status = "published", published_at = null, last_reviewed_at = 2026-08-05T…

一篇 status 为 published、published_at 为 null 的文章。all_articles 视图按 published_at desc 排序、published_articles 也读这个字段,这行的排位与展示都无定义。

可达性:需要一个在 update 时回传只读字段的客户端。Console 表单把 published_at 渲染成只读、不提交,所以今天的 UI 路径不触发;REST/集成调用方整记录回传是常见写法。据此判断这半条偏 observation-class,第 1 点则是今天就通的。

#780 的关系(对照实测,证明不是 #780 引入的)

同一个探针,分别跑在 #780 修复前后的 hook 上:

                                  修复前            修复后
archived -> re-published          戳被移到今天       保持原始日期   ← #780 修的
insert 带 published_at(非系统)    被覆写成今天       保留 2024-03-01
update 带 published_at(非系统)    published_at=null  published_at=null   ← 两侧相同

第 3 行两侧一致,所以第 2 点是既有行为,不是 #780 的修复引入的。第 2 行的差异是 #780 修复的正向结果(不再覆写调用方给的历史日期)—— 但它也让第 1 点的后果更实:修复前 hook 会把非法写入的 published_at 顺手盖掉,相当于偶然地掩盖了 insert 路径不设防这件事;修复后调用方给什么就存什么。这不是修复的缺陷(保留导入历史正是 #780 的裁定),而是把第 1 点从「被掩盖」变成「可见」,所以一并记在这里。

归属与建议

两点都是引擎写路径的行为,大概率需要 upstream 定夺:

  1. readonly 到底是「不可写」还是「建后不可改」—— 定了之后,要么 insert 路径补上剥离,要么本仓把这批字段的注释/文档改成后一种语义(并接受 create 时可被填任意值)。
  2. stripReadonlyFields 删的应当是「调用方给的那个值」,而不是「该 key 上的当前值」—— hook 在剥离之前写入的派生值不该被调用方的一个回传字段连坐。

本仓能先做的:把「已发布但无发布日期」这一行状态挡在校验规则里(而不是靠 hook 的戳),或者在文档里明确 readonly 的实际边界。两条都是行为决策,先定夺再动手。

复现

ObjectQL.create({ datasources: { default: new InMemoryDriver({ persistence: false }) }, objects: { crm_knowledge_article } }),bindHooksToEngine 绑上 sys_fetch_previous_update 的复刻(priority 5 / beforeUpdate / object *,真内建是 kernel 服务的 private registerAuditHooks() 装的,裸引擎拿不到)与真 knowledge_article.hook,然后用非系统上下文按上表三种写法各写一次,读回即见。探针脚本按越界规则未入库。

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions