Skip to content

data import: a "historical" import can't preserve original timestamps / audit fields — updated_at is stamped now, readonly fields stripped on upsert (#3479 follow-up) #3493

Description

@os-zhuang

现象

#3479 / #3483treatAsHistorical 导入跳过 state_machine,解决了「历史 mid-lifecycle 行被 initialStates 拒」。但历史数据迁移的另一半诉求——保留原始时间线——仍不成立:

  • 导入一张 2020 年创建、2021 年关闭的工单,存储后 updated_at 显示导入当天,updated_by 显示导入者,而不是原始值。
  • upsert 刷新一批历史行时,业务 readonly 字段(如 closed_atresolved_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 优先:

  1. auto-stamp 尊重 client 值:当 context 带 skipStateMachine/treatAsHistorical(或新 preserveAudit 旗标)时,updated_at/updated_by 也改成 ?? (client 优先),与 created_at 对齐。
  2. readonly strip 放宽:同一旗标下,stripReadonlyFields 放行白名单内的历史字段(而非无差别放行,避免变成篡改 created_by/owner 的后门)。或收窄到「审计/时间戳字段族 + 对象显式声明可迁移的 readonly 字段」。

设计约束:

  • 必须保持 opt-in(treatAsHistorical 已是),普通写入绝不能让 client 篡改 updated_at/审计字段。
  • 不能退化成「非 system = 随便改审计」——建议白名单而非 isSystem 全豁免。
  • objectui 导入向导已有「Import as historical data」复选框(objectui#2815),此项若落地,该复选框的语义自然延伸,无需新 UI。

复现

  1. treatAsHistorical: true 导入一行显式带 updated_at: '2021-03-01T...' 的记录 → 查库,updated_at = 导入当天,非 2021。
  2. writeMode: 'upsert' + treatAsHistorical: true 刷新一行,payload 带一个声明为 readonly 的业务字段(如 closed_at)→ 该字段被 strip,未写入。

参考

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions