Repository navigation
fix(core)!: an update/upsert import refuses its own rows that share a match key (DUPLICATE_MATCH_KEY) - #26250
Conversation
… base) Claude-Session: https://claude.ai/code/session_01LDVZZPEWCh6ycbrhnDCamc Co-authored-by: Claude <noreply@anthropic.com>
…write Rows of one update/upsert import whose coerced match key repeats now all fail with DUPLICATE_MATCH_KEY, naming every row that carries the key, and none of them is written, on a dry run as on the import. Files without in-file duplicates answer as before. The new row code lands in the error-code ledger with the runner as its reader, and the import contract and the per-row outcome table say so. Claude-Session: https://claude.ai/code/session_01LDVZZPEWCh6ycbrhnDCamc Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LDVZZPEWCh6ycbrhnDCamc Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LDVZZPEWCh6ycbrhnDCamc Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LDVZZPEWCh6ycbrhnDCamc Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 151 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 935d80a3d4b6e337314997b83aee55fd651d2106 && git checkout 935d80a3d4b6e337314997b83aee55fd651d2106
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6862ef62abd9062b54e92a8d8f82ec1abf1320c1 9cb2180be17e97f065d85cd64c7816b031cd2344 && git checkout -B drift-repro 6862ef62abd9062b54e92a8d8f82ec1abf1320c1 && git merge --no-ff 9cb2180be17e97f065d85cd64c7816b031cd2344
node scripts/docs-audit/affected-docs.mjs --json 6862ef62abd9062b54e92a8d8f82ec1abf1320c1
|
Contract reviewServed-tier: Face note: the brief asked for the narrowing face; ① Derived judgmentsAccept-set and public-surface changes the diff implies, each judged:
One boundary the claim text does not name (non-blocking, no local run): a key column that is a lookup to THIS object, pointing at a row the same file creates earlier, resolves Check-runs on the head, read 2026-10-26T17:02Z: success on Governed Surface Queue Guard, Check Changeset, Check PR Size, Lint ESLint, Lint CI release and engine gates, Lint repo and tooling gates, Lint docs and code guards, Lint scoped (tooling self-tests, slot lookup and corpus, query-options erasure), Lint PM dispatch-gates, Type Check source gates, Build Docs, Build UI Deps, Dogfood Regression Gate, Spec property liveness, Flag docs, Check Documentation Links, the card-claims-branch, part-of and single-writer guards, Auto Label, filter. Still pending at that read: Lint and Repo Gates (queued aggregate), Test Core 1 through 8, Build Core, Type Check workspace, Type Check debt ledger, Type Check consumer gates, Type Check UI, Build and E2E UI. Skipped by path filter: Dogfood Verify CLI and shards, Temporal Conformance, Lint UI, Test UI, the opt-in tarball and 18.x harness. Nothing red. The queue lands only on every required context green, so the pending set is the queue's to wait for, not this record's to pre-judge. ② Semver levelChangeset ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #26133
Clause-②: no (narrowing: rows of one
update/upsertimport that carry the same coercedmatchFieldskey, which used to be written one by one, now all fail withDUPLICATE_MATCH_KEYand none of them is written)Metadata-protocol: none
What changes
One
update/upsertimport never writes two of its own rows for onematchFieldskey. Before any row is written,runImport(packages/core/src/utils/import-runner.ts) groups the rows by their key, and every row of a key carried more than once fails withok: false,action: 'failed',code: 'DUPLICATE_MATCH_KEY', itserrornaming every row that carries the key and the key set. None of those rows is written; the rest of the file imports as before. The dry run answers the same rows, from the same code path.coerceRow(the one coercion, ⛔ no second normalisation copy) over each row's key columns with the same coercion context the row's own pass uses, then reads the key set bykeySetOf, the helperfindExistingnow shares. SoISO 14001(option label) andISO14001(option value) are one key. A row blank on every key set, or whose key cells fail coercion, has no key and answers as before.updatefile with two rows of one stored key answeredupdated,updatedfor the same id, and the last row silently won, the merge the triage ruled out.insertmatches nothing and still writes every row.created+UNIQUE_VIOLATIONon the write andcreated,createdon the dry run. Keeping that path would need a runner-side copy of the engine's uniqueness rules; with the check it answersDUPLICATE_MATCH_KEYfor both rows on both halves.DUPLICATE_MATCH_KEYlands inERROR_CODE_LEDGER(@objectstack/core's list) with the runner as its reader, and thematchFieldsdescription inapi/export.zod.tssays what it means. Generated references regenerated (gen:docs). The hand-written per-row outcome table incontent/docs/data-modeling/import-mappings.mdxgains the row.Evidence
Red first, through the real
POST /data/:object/importroute over a realSqlDriver(better-sqlite3:memory:):packages/rest/src/import-upsert-in-file-duplicate-key.test.ts, whole per-row results pinned withtoEqual.c7aa9cde24Acme, ISO 9001, A-1created,created(2 records)DUPLICATE_MATCH_KEY, 0 recordsISO 14001, B-1thenISO14001, B-1created,createdDUPLICATE_MATCH_KEYfailed: AMBIGUOUS_MATCHupdated(after the fixed file created it)updated,updated, last row winsDUPLICATE_MATCH_KEY, record unchangedcreated+UNIQUE_VIOLATION; dry runcreated,createdDUPLICATE_MATCH_KEYupdated,created, blank-keycreated×2if (writeMode === 'insert') return shared;replaced by an always-return throughscripts/ablation-replace.mjs,ablation-dist-preflightfound the marker inpackages/core/dist/index.{js,cjs}, the file went 8 failed / 1 passed; restored (blob == HEAD,git diff HEADempty), rebuilt, preflight--absentclean, 9 of 9.Local runs at
9cb2180be(the head this PR opens with):@objectstack/coretest + typecheck: 121 files, 2778 tests passed.@objectstack/resttest + typecheck: 323 files, 6478 passed, 326 skipped.@objectstack/service-automationconnector-pull suites 22 of 22;@objectstack/plugin-authuser-import suites 80 of 80.(narrowing)arm): 296 files passed, 3 skipped; 2523 tests passed, 18 skipped; lock VERDICT command-exit 0.dispatch-gates --commands: 116 commands, all exit 0;--ranreconciles 116 derived, 116 run, 0 NOT-MEASURED.pnpm --filter @objectstack/spec check:generated: all 14 artifacts up to date. eslint (pnpm lint's command) overpackages/core packages/rest packages/spec: exit 0.check-governed-merges --branch: 0 of 10 paths governed, 393 changed lines; not Tier H.Acceptance notes
where. Under a case-insensitive MySQL collationA-1anda-1are one stored key but two in-file keys, so such a pair would still both be created there. No MySQL cell was run.ui/packages/plugin-gridImportWizardmaps a few row codes to translated sentences (AMBIGUOUS_MATCHamong them);DUPLICATE_MATCH_KEYfalls through to the server's Englisherror, which names the rows. Noted, not filed.upsertKey) and the user import (email/phone_number) run the samerunImport, so a pull page or a user sheet that repeats a key now gets those rows back asDUPLICATE_MATCH_KEYtoo; their existing suites pass unchanged.content/docs/data-modeling/import-mappings.mdx(one table row) and the fourcontent/docs/references/api/*.mdxfilesgen:docsregenerated.Generated by Claude Code