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

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 externalId → name → id,全部精确匹配 —— 多探测只可能救回引用,不可能误配。
  • 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.

This branch was successfully deployed

1 active deployment
Preview — f68047d7 Deployed Jul 17, 2026 by vercel[bot]
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