Skip to content

fix(seed): seed replay must not corrupt lookup natural keys on the upsert update path#3097

Merged
os-zhuang merged 1 commit into
mainfrom
claude/lucid-wright-60ea9e
Jul 17, 2026
Merged

fix(seed): seed replay must not corrupt lookup natural keys on the upsert update path#3097
os-zhuang merged 1 commit into
mainfrom
claude/lucid-wright-60ea9e

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

问题

第三方评估(15.1.x,2026-07-17,记忆 project_thirdparty_eval_15_1_findings)实测:每次 dev server 重启,seed upsert 重放都会破坏以 natural key 书写的 lookup/master_detail 引用 ——

update my_app_interview set candidate = NULL … NOT NULL constraint failed: my_app_interview.candidate

只有字段恰好 required(NOT NULL 列)才拦住了写入;可空 lookup 会被静默清成 NULL = 数据破坏。首次启动 insert 路径一切正常,掩盖了问题。

根因链(三层叠加)

用原评估工程(15.1.0 发布包 + 其 dev.db 现场)完整复现,链路为:

  1. 父记录 update 被合理拒绝后断链:评估者在 kanban 上拖动过 candidate.stage,重放想写回 seed 值被 state_machine 规则拒绝(合理)。但 update 失败的 catch 分支不登记 insertedRecords 的 externalId → id 映射,导致所有子 dataset 的内存 natural-key 解析全部失联
  2. DB 回退探测列硬编码 name:resolveFromDatabase 不知道目标 dataset 声明的 externalId: 'email',拿 where name = 'alice@example.com' 去查(name 实际是 Alice Zhang)必然落空。首次启动全靠内存解析,从未暴露。
  3. multiPass defer 直接把 NULL 写穿 update:解析失败后 record[field] = null 随 pass 1 的 update 落库,覆盖已存在行的正确引用 —— pass 2 也救不回(同样解析不到)。

修复(SeedLoaderService,单一实现,三条生产路径共享)

  • 解析失败绝不写 NULL:defer 改为移除字段(insert 缺列=占位待 pass 2;update 不碰该列=保留现值);非 multiPass 下彻底解析不到则整条记录丢弃并大声报错(计入 errored),绝不把 natural-key 字符串或 NULL 写进 FK 列。object-wrapper 守卫同样改为移除字段。
  • DB 探测按目标 dataset 的 externalId:探测顺序 dataset externalIdnameid,全部精确匹配 —— 多探测只可能救回引用,不可能误配。
  • update 先登记映射再写:行存在与否与写入成败无关,state_machine 拒绝不再级联断链(批量路径与 self-ref 路径都修)。
  • 幂等重放(评估发现的顺带项):upsert/update 时若 seed 声明的字段与现有行完全一致 → skip。消除每次重启的 updated_at 刷新、生命周期重校验与 FSM 误拒;比较是保守宽松(driver 往返的布尔 0/1、日期字符串、数字字符串),拿不准一律按"有变化"回退老行为。

验证

  • 新回归测试 seed-loader-replay.test.ts(5 个,mock 的 find 真正按 where 过滤 —— 旧 mock 返回全表,正好掩盖缺陷 2):两次 load 重放、父 update 被拒的级联、externalId DB 探测、defer 不碰列、单 pass 丢记录、无变化全跳过。对未修复代码 4/5 红(stash 验证),修复后全绿。
  • 真实栈端到端(AppPlugin → ObjectQL → SqlDriver,三次 boot,含用户改行 + FSM 拒绝):6 项检查全 PASS —— 子表引用保持正确 id、重放更新正常落地、FSM 拒绝只报错不级联、第三次 boot 完全幂等(零 update、零 updated_at 变化)。
  • 受影响包全测:metadata-protocol 34 ✓ / runtime 544 ✓ / objectql 904 ✓ / spec 6888 ✓ / cloud-connection ✓。

影响面

  • packages/metadata-protocol/src/seed-loader.ts 单文件实现;runtime / objectql 仅 re-export,REST publish、单租户 inline seed、多租户 per-org replayer 三条路径同时受益。
  • 行为变化:重放无变化记录从 updated 变为 skipped(summary 计数语义不变);非 multiPass 解析失败从"带未解析字符串写入"变为"丢弃并报错"(更严格,LOUD)。

🤖 Generated with Claude Code

…sert update path

Third-party eval (15.1.x, 2026-07-17): every dev-server restart, the seed
replay severed lookups authored as natural keys — `update my_app_interview
set candidate = NULL … NOT NULL constraint failed`. Only the NOT NULL
column stood between the replay and silent data loss; a nullable lookup
would have been cleared without a trace.

Root-cause chain (three stacked defects):
1. A parent update legitimately rejected by a state_machine rule (user had
   edited rows at runtime) did not register the row's externalId → id
   mapping, severing in-memory natural-key resolution for every child
   dataset.
2. The DB fallback probe hardcoded `name` as the match column instead of
   the target dataset's declared externalId (`email`), so it could never
   rescue the miss — first boot masked this because in-memory resolution
   handled everything.
3. The multi-pass defer path wrote `record[field] = null` straight through
   the update, overwriting the existing row's correct reference on every
   replay.

Fixes:
- Resolution failures never write NULL (or the raw natural-key string)
  over an existing row: deferred references leave the column untouched
  for pass 2; a definitively unresolvable reference drops the record
  loudly (counted + reported).
- resolveFromDatabase probes the target dataset's externalId first, then
  `name`, then `id` — exact matches only, so extra probes can rescue but
  never mis-resolve.
- update registers externalId → id before attempting the write (the row
  exists regardless of the write's outcome), on both the batched and the
  self-referencing paths.
- Idempotent replay: an upsert/update whose declared fields already match
  the existing row is skipped — no updated_at churn, no lifecycle
  re-validation, no state_machine veto on every boot.

Regression tests replay seeds twice through a faithful engine mock (where-
filtering, unlike the legacy mock that returned the whole table and masked
the DB-probe bug), covering the rejected-parent cascade, the externalId DB
probe, defer-must-not-touch, single-pass record drop, and no-op skips.
Verified end-to-end on the real stack (AppPlugin → ObjectQL → SqlDriver,
three boots incl. user-edited rows + FSM veto): links intact, zero
constraint errors, idempotent third boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 17, 2026 7:18am

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Jul 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/runtime.

17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang merged commit 515f11a into main Jul 17, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/lucid-wright-60ea9e branch July 17, 2026 07:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant