现象
#3479 / #3483 让 treatAsHistorical 导入跳过 state_machine,解决了「历史 mid-lifecycle 行被 initialStates 拒」。但历史数据迁移的另一半诉求——保留原始时间线——仍不成立:
- 导入一张 2020 年创建、2021 年关闭的工单,存储后
updated_at 显示导入当天,updated_by 显示导入者,而不是原始值。
- 用
upsert 刷新一批历史行时,业务 readonly 字段(如 closed_at、resolved_by)被静默 strip 掉。
结果:迁移进来的历史数据时间线错乱(所有记录看起来都是今天改的),报表/审计/排序全部失真。treatAsHistorical 只解决了 FSM 那一半。
机制(两层,均有确切落点)
① 审计 auto-stamp(packages/objectql/src/plugin.ts:762-772)——code-registered hook,skipAutomations 不豁免它:
if (isInsert) record.created_at = record.created_at ?? now; // ✓ client 优先
record.updated_at = now; // ✗ 无条件覆盖(insert+update)
if (isInsert && hasField(..., 'created_by')) record.created_by = record.created_by ?? session.userId; // ✓ client 优先
if (hasField(..., 'updated_by')) record.updated_by = session.userId; // ✗ 无条件覆盖
created_at / created_by 用 ??,insert 时能保留 client 提供的历史值;但 updated_at / updated_by 是无条件覆盖,历史「最后修改」信息永远丢失。
② readonly 字段 strip(packages/objectql/src/engine.ts:2869 与 :2924,规则见 #2948):
if (!opCtx.context?.isSystem) {
hookContext.input.data = stripReadonlyFields(updateSchema, preRo, suppliedKeys, this.logger);
}
import 的 writeCtx 是非 system(context 由 resolve-execution-context.ts 从 session 派生,isSystem: false),所以 upsert 刷新走 update 路径时,调用方提供的业务 readonly 字段(closed_at 等)被 strip。
字段保真度现状
| 字段类 |
insert |
upsert 刷新(update) |
业务 readonly(closed_at) |
✓ 可设 |
✗ stripReadonlyFields strip |
created_at / created_by |
✓ ?? client 优先 |
(update 不写) |
updated_at / updated_by |
✗ 无条件 stamp now / 导入者 |
✗ 无条件 stamp |
影响面
候选修法(需拍板)
方向是让 treatAsHistorical(或一个更聚焦的子选项如 preserveAudit)在这一次导入里,让审计/时间戳字段 client 优先:
- auto-stamp 尊重 client 值:当 context 带
skipStateMachine/treatAsHistorical(或新 preserveAudit 旗标)时,updated_at/updated_by 也改成 ?? (client 优先),与 created_at 对齐。
- readonly strip 放宽:同一旗标下,
stripReadonlyFields 放行白名单内的历史字段(而非无差别放行,避免变成篡改 created_by/owner 的后门)。或收窄到「审计/时间戳字段族 + 对象显式声明可迁移的 readonly 字段」。
设计约束:
- 必须保持 opt-in(
treatAsHistorical 已是),普通写入绝不能让 client 篡改 updated_at/审计字段。
- 不能退化成「非 system = 随便改审计」——建议白名单而非
isSystem 全豁免。
- objectui 导入向导已有「Import as historical data」复选框(objectui#2815),此项若落地,该复选框的语义自然延伸,无需新 UI。
复现
treatAsHistorical: true 导入一行显式带 updated_at: '2021-03-01T...' 的记录 → 查库,updated_at = 导入当天,非 2021。
writeMode: 'upsert' + treatAsHistorical: true 刷新一行,payload 带一个声明为 readonly 的业务字段(如 closed_at)→ 该字段被 strip,未写入。
参考
🤖 Generated with Claude Code
现象
#3479 / #3483 让
treatAsHistorical导入跳过state_machine,解决了「历史 mid-lifecycle 行被 initialStates 拒」。但历史数据迁移的另一半诉求——保留原始时间线——仍不成立:updated_at显示导入当天,updated_by显示导入者,而不是原始值。upsert刷新一批历史行时,业务readonly字段(如closed_at、resolved_by)被静默 strip 掉。结果:迁移进来的历史数据时间线错乱(所有记录看起来都是今天改的),报表/审计/排序全部失真。
treatAsHistorical只解决了 FSM 那一半。机制(两层,均有确切落点)
① 审计 auto-stamp(
packages/objectql/src/plugin.ts:762-772)——code-registered hook,skipAutomations不豁免它:created_at/created_by用??,insert 时能保留 client 提供的历史值;但updated_at/updated_by是无条件覆盖,历史「最后修改」信息永远丢失。② readonly 字段 strip(
packages/objectql/src/engine.ts:2869与:2924,规则见 #2948):import 的 writeCtx 是非 system(context 由
resolve-execution-context.ts从 session 派生,isSystem: false),所以upsert刷新走 update 路径时,调用方提供的业务readonly字段(closed_at等)被 strip。字段保真度现状
closed_at)created_at/created_by??client 优先updated_at/updated_by影响面
treatAsHistorical的核心场景,但只做了一半:FSM 不拦了,时间线却仍被平台改写。用户从旧系统迁一批工单/订单/case,updated_at全变成迁移日 → 「最近修改」排序、SLA 报表、审计全错。候选修法(需拍板)
方向是让
treatAsHistorical(或一个更聚焦的子选项如preserveAudit)在这一次导入里,让审计/时间戳字段 client 优先:skipStateMachine/treatAsHistorical(或新preserveAudit旗标)时,updated_at/updated_by也改成??(client 优先),与created_at对齐。stripReadonlyFields放行白名单内的历史字段(而非无差别放行,避免变成篡改created_by/owner的后门)。或收窄到「审计/时间戳字段族 + 对象显式声明可迁移的 readonly 字段」。设计约束:
treatAsHistorical已是),普通写入绝不能让 client 篡改updated_at/审计字段。isSystem全豁免。复现
treatAsHistorical: true导入一行显式带updated_at: '2021-03-01T...'的记录 → 查库,updated_at= 导入当天,非 2021。writeMode: 'upsert'+treatAsHistorical: true刷新一行,payload 带一个声明为readonly的业务字段(如closed_at)→ 该字段被 strip,未写入。参考
treatAsHistorical— FSM 那一半)、objectui#2815(向导复选框)、packages/objectql/src/plugin.ts(auto-stamp hook)、packages/objectql/src/engine.ts+ security: 服务端 readonly 字段在 UPDATE 时未强制(可被用户上下文覆盖) #2948(stripReadonlyFields)、packages/runtime/src/security/resolve-execution-context.ts(import 是非 system context)。🤖 Generated with Claude Code