Skip to content

fix(seed-loader): support composite externalId so join-table seeds dedupe on replay (#3434) - #3442

Merged
os-zhuang merged 2 commits into
mainfrom
claude/seed-loader-insert-duplicates-vlywdf
Jul 24, 2026
Merged

fix(seed-loader): support composite externalId so join-table seeds dedupe on replay (#3434)#3442
os-zhuang merged 2 commits into
mainfrom
claude/seed-loader-insert-duplicates-vlywdf

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

背景

mode: 'insert' 的 dataset 在每次 boot 重放时无条件重插——decideWriteActioninsert case 直接 return { action: 'insert' },从不查 existing。join 表(junction table)没有单字段天然唯一键——唯一的只有两个外键的组合——所以它只能用 insert,于是每次重启都重复入库:showcase 的 showcase_project_membership(3 条)实测 3 → 6 → 9。此前被 #3415 掩盖(membership 的 master-detail 父项目缺失、0 行入库),#3415 修好后立即显形。

Issue 给了两个方向:方向 1(引擎支持组合键,更根治,任何 join 表种子都会遇到)与方向 2(给对象加一个显式 name 字段当 externalId,一行可落)。本 PR 采用方向 1——按 Prime Directive #5(不做临时补丁,采用可持续方案)。给纯 join 对象塞一个冗余 name 字段只是为了迁就 seed loader,属于 workaround;junction 对象本就应以组合外键为天然键,seed loader 应当支持。

改动

  • packages/spec/src/data/seed.zod.tsSeedSchema.externalId 现同时接受单字段名字段名列表(externalId: ['team', 'project']),用于声明组合天然键;默认仍为 'name'
  • packages/metadata-protocol/src/seed-loader.ts — 新增单一的 externalIdKey 辅助方法,用 NUL(``,天然键值不可能包含)拼接各字段值构成唯一键,并贯穿所有构键调用点(loadExistingRecords / `decideWriteAction` / `writeRecord` / 批量插入 `extIdOf` / deferred updates / `buildWriteError`)。
    • 引用型键字段按已解析的父记录 id 比较——重放时既有 DB 行存的正是该 id,所以外键组合能跨重启匹配到既有行。
    • 任一分量缺失 → 视作“无键”,回退为插入(与单字段缺失时行为一致)。
    • 单字段路径逐字节保持原行为
    • 组合键对象不参与单值引用解析(引用值是单个天然键字符串),因此当它被别的 dataset 引用时仍走 'name' 默认。
  • examples/app-showcase/src/data/seed/index.ts — membership fixture 改为 mode: 'ignore' + externalId: ['team', 'project'],重放 boot 不再复制这三行。

测试

  • 新增 seed-loader-composite-external-id.test.ts(faithful engine):首 boot 三个 (team, project) 组合各插一次;重放 boot 全部 skip(仍是 3 行,不再 3→6→9);第三个 boot 仍 3 行;共享同一 team/project 的新组合仍会插入,不会被误判为重复。
  • 新增 spec schema 测试:接受组合 externalId,拒绝空数组。
  • 回归:metadata-protocol(16)、spec data/seed(60)、runtime seed-loader(41,含 mode:'insert')、objectql seed-loader(7)、app-showcase seed(4)、app-plugin.seed + 真实驱动 bulk-write(9)全部通过。
  • 全仓 pnpm build(71/71)通过。

Closes#3434

🤖 Generated with Claude Code

https://claude.ai/code/session_01JqZsDkKaCkpQGpJdEMEWDF


Generated by Claude Code

…dupe on replay (#3434)
A junction / join table has no single-field natural key — the PAIR of its
foreign keys is what's unique — so its seed could only run `mode: 'insert'`,
which re-inserts every row on each replay boot with no existing-row check
(`decideWriteAction`'s `insert` case returned `insert` unconditionally). The
table duplicated on every restart: the showcase `showcase_project_membership`
fixture (3 rows) grew 3 → 6 → 9. It stayed hidden until #3415 let the
master-detail parents seed at all.
- `SeedSchema.externalId` now accepts a list of field names
(`externalId: ['team', 'project']`) as well as a single field name,
declaring a composite natural key. Default stays 'name'.
- `SeedLoaderService` builds the uniqueness key from all listed fields (joined
with a NUL separator that can't occur in a natural-key value) via a single
`externalIdKey` helper threaded through every key-building call site.
Reference key fields are compared by their RESOLVED parent ids — which the
existing DB row already stores — so a composite of foreign keys matches
across restarts. A partial key (any component absent) is treated as no key,
falling back to insert, exactly as a missing single-field key already did.
Single-field behavior is byte-for-byte unchanged.
- A composite-key target does not participate in single-value reference
resolution (a reference is one natural-key string), so such objects keep the
'name' default when referenced by another dataset.
The showcase membership fixture switches to `mode: 'ignore'` +
`externalId: ['team', 'project']`, so replay boots leave the three rows
untouched instead of duplicating them.
Tests: new composite-key replay test (insert-once then skip-all on replay,
and a new pair still inserts while sharing rows don't block it) plus a spec
schema test; existing single-field seed-loader suites unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqZsDkKaCkpQGpJdEMEWDF
@vercel

vercelBot commented Jul 24, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specCanceledCanceledJul 24, 2026 2:55pm

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling size/m labels Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via packages/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol, packages/spec)
  • content/docs/concepts/north-star.mdx(via packages/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via packages/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via packages/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/spec)
  • content/docs/permissions/authorization.mdx(via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx(via @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

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.

`pnpm --filter @objectstack/spec gen:schema && gen:docs` after the
`SeedSchema.externalId` union change. These files are generated from the Zod
schema (Prime Directive #1) and the check:docs CI gate flagged them stale.
Only the externalId type row changes (`string` → `string | string[]`) across
seed, seed-loader, and manifest reference tables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqZsDkKaCkpQGpJdEMEWDF
@os-zhuang
os-zhuang marked this pull request as ready for review July 24, 2026 15:04
@os-zhuang
os-zhuang merged commit abceb0d into mainJul 24, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/seed-loader-insert-duplicates-vlywdf branch July 24, 2026 15:04
os-zhuang added a commit that referenced this pull request Jul 24, 2026
…osite externalId (#3460)
Follow-up to #3442 (which closed#3434).
- lint: add validateSeedReplaySafety (@objectstack/lint, a pure (stack) => Finding[]
rule per ADR-0019) and wire it into os validate / os lint. It flags every
data[] seed declared mode:'insert' — the one non-idempotent mode, which
duplicates its table on every replay boot — and points the author at
ignore/upsert plus an externalId (single field, or a composite list for a
join table). Catches the #3434 footgun at authoring time.
- docs: correct the seed insert-mode row in seed-data.mdx, add an insert
replay-safety warning, and add a "Composite externalId — Join / Junction
Tables" section plus best-practice and API-reference updates.
Verified on a real better-sqlite3 file DB with the real showcase
ProjectMembership object: mode:'insert' reproduces 3 -> 6 -> 9 across reboots,
while mode:'ignore' + externalId:['team','project'] stays 3 -> 3 -> 3.
Refs #3434, #3442
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqZsDkKaCkpQGpJdEMEWDF
os-zhuang added a commit that referenced this pull request Jul 24, 2026
…3433 follow-up) (#3471)
Follow-ups to the #3433 seed / state-machine exemption:
- lint: `validateSeedStateMachine` — an author-time `os validate` / `os lint`
warning when a seed record's `state_machine`-governed field carries a value the
machine does not declare (initialStates ∪ transition keys ∪ targets). #3433
exempts seeds from the FSM at write time, so this re-adds the "is this even a
known state?" safety net a typo would otherwise slip past. Advisory, symmetric
with the #3434 replay-safety rule. Rule id `seed-value-outside-state-machine`.
- test(cloud-connection): a marketplace-install integration test that drives the
real install handler → runInlineSeed → SeedLoaderService against an engine stub
reproducing the #3165 initialStates guard, proving a template whose seed spans
the whole pipeline (prospecting → closed_won) lands every row on install. Red
without the #3433 exemption (verified).
- docs: note the seed exemption in the state-machine, validation, and seed-data
guides — seeds are established facts, not lifecycle events; every other
validation still runs; `os lint` catches an unknown-state typo before boot.
The remaining #3433 follow-up — #3434 (mode:'insert' seeds duplicate on replay) —
was already closed by #3442 (composite externalId) + #3460 (replay-safety lint),
so it needs no change here.
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

seed-loader: mode:'insert' datasets duplicate on every replay boot — showcase memberships 3→6→9 (needs composite externalId or explicit key)

2 participants

@os-zhuang@claude