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

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
spec Canceled Canceled Jul 24, 2026 2:55pm

Request Review

@github-actions github-actions Bot 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 main Jul 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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

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