fix(seed): seed replay must not corrupt lookup natural keys on the upsert update path#3097
Merged
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题
第三方评估(15.1.x,2026-07-17,记忆
project_thirdparty_eval_15_1_findings)实测:每次 dev server 重启,seed upsert 重放都会破坏以 natural key 书写的 lookup/master_detail 引用 ——只有字段恰好 required(NOT NULL 列)才拦住了写入;可空 lookup 会被静默清成 NULL = 数据破坏。首次启动 insert 路径一切正常,掩盖了问题。
根因链(三层叠加)
用原评估工程(15.1.0 发布包 + 其 dev.db 现场)完整复现,链路为:
state_machine规则拒绝(合理)。但 update 失败的 catch 分支不登记insertedRecords的 externalId → id 映射,导致所有子 dataset 的内存 natural-key 解析全部失联。name:resolveFromDatabase不知道目标 dataset 声明的externalId: 'email',拿where name = 'alice@example.com'去查(name 实际是Alice Zhang)必然落空。首次启动全靠内存解析,从未暴露。record[field] = null随 pass 1 的 update 落库,覆盖已存在行的正确引用 —— pass 2 也救不回(同样解析不到)。修复(
SeedLoaderService,单一实现,三条生产路径共享)dataset externalId→name→id,全部精确匹配 —— 多探测只可能救回引用,不可能误配。state_machine拒绝不再级联断链(批量路径与 self-ref 路径都修)。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 验证),修复后全绿。影响面
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