Skip to content

唯一约束冲突没有单一判别谓词:仓内四套各自为政的方言词表,REST 的 409 映射漏掉 MySQL(Duplicate entry 落成 500 INTERNAL_ERROR) #6250

Closed

Description

@baozhoutao

发现于 #5495 的前提复核(engine-core 车道),未在该单 PR 内顺手修,按 Prime Directive #10 单独开单。

缺陷

「这个驱动错误是不是唯一约束冲突」这件事,仓内有四份互不相同的手抄实现,没有一个共享谓词:

位置 判别方式 覆盖
packages/services/service-messaging/src/messaging-service.ts:27 isUniqueViolation() 3 个 code(23505 / ER_DUP_ENTRY / SQLITE_CONSTRAINT_UNIQUE)+ 3 个消息子串(unique constraint failed / duplicate key / duplicate entry) 三方言齐全
packages/rest/src/rest-server.ts:948 仅 lower.includes('unique constraint') || lower.includes('unique violation') 漏 MySQL
packages/rest/src/import-runner.ts:186 一条 Postgres 专用正则,顺带抽冲突列名 仅 Postgres
packages/drivers/driver-sql/src/sql-driver.ts:5664 行内正则 /unique constraint failed|duplicate entry|duplicate key value/i 三方言齐全

用户可见后果:MySQL 上的唯一冲突返回 500 而不是 409

rest-server.ts 的错误映射先过 looksLikeInternalErrorLeak()(packages/types),再在其内部判 409。MySQL 的唯一冲突消息形如:

ER_DUP_ENTRY: Duplicate entry 'acme@example.com' for key 'idx_email_unique'

逐条比对 looksLikeInternalErrorLeak 的判据 —— sqlite_ / sqlstate / constraint failed / unique constraint / foreign key / 以 insert into 、update 、select 、delete from 开头 —— 一条都不命中,所以它连 409 分支所在的那个 if 都进不去,直接落到 UNCLASSIFIED_FAULT():

const UNCLASSIFIED_FAULT = () => ({
  status: 500,
  body: { error: INTERNAL_ERROR_MESSAGE, code: 'INTERNAL_ERROR' },
});

即:MySQL 部署下,每一次唯一约束冲突都是 500 INTERNAL_ERROR,而不是 API 契约声明的 409 UNIQUE_VIOLATION(UNIQUE_VIOLATION 在 packages/spec/src/api/error-code-ledger.zod.ts:117 是登记在册的错误码)。前端拿不到「这个值已存在」,只能看到一个通用服务端故障。SQLite 与 Postgres 的消息恰好命中子串,所以这个洞在这两种方言上是隐形的。

这正是 #5841 修过的缺陷类型,只是换了个谓词

#5841 把「表未 provision」的判别收敛成一个具名谓词 isMissingTableError(packages/metadata/src/errors.ts:50 导出),明确点名「第二套良性词表」是缺陷本身;scripts/check-durability-degradation-log-level.mjs 的 READ_FAILURE_DISCRIMINATORS 按谓词名登记它。唯一冲突判别今天正处在 isMissingTableError 落地之前的状态,而且已经分叉到四份。

它挡住了什么

#5495 的裁向之一是「撞 UNIQUE 且冲突列是本 autonumber 字段时,重同步 + 有界重试;非本字段的冲突原样上抛」。这需要生产侧提供带冲突列名的结构化唯一冲突错误 —— 今天不存在:四份实现里只有 import-runner 那条 Postgres 正则抽列名,且只在 Postgres 上有效。在 engine 里手抄第五套方言词表来做重试判据,恰好是 Prime Directive #12 禁止的消费者侧宽容解析。所以 #5495 的该分支被本条阻塞。

建议方向(不预设结论)

把 service-messaging 那份最完整的实现提取成具名共享谓词(落点候选:@objectstack/types,四个消费者都已依赖它;或参照 #5841 走 metadata/errors 那条子路径),并考虑同时提供「冲突列名」的结构化出口 —— 后者是新契约面,需要维护者定夺,不应由某个 bug 修复单顺手拍。

Activity

  1. claude commented on Aug 7, 2026

    @claude
    Contributor

    Triage: pm:queue + domain:cli + target:v17.

    Landing site — stated explicitly because this issue spans four files. The user-visible half lands in packages/rest/src/rest-server.ts and the leading home for the shared predicate is @objectstack/types (the body's own first candidate, and the one all four consumers already depend on). Both are in the domain:cli lane per the lane table (packages/rest, types). Verified on origin/main@2598216:

    949:        if (lower.includes('unique constraint') || lower.includes('unique violation')) {
    954:                    code: 'UNIQUE_VIOLATION',
    

    — MySQL's ER_DUP_ENTRY: Duplicate entry '…' for key '…' matches neither substring, so it never reaches the 409 branch.

    ⚠️ Routing caveat, flagged rather than hidden. If the maintainer or the claiming dev prefers the route #5841 used — a named predicate under packages/metadata/errors, registered by name in scripts/check-durability-degradation-log-level.mjs — the shared-predicate half would land in domain:engine-core instead. The label follows the dominant landing site (the REST 409 mapping is the defect users hit, and it is unambiguously in this lane). If the route changes at implementation time, raise it as a mislabel rather than re-labelling in place — domain:* has a single producer.

    Release board (target:v17). Criterion ②: UNIQUE_VIOLATION is a registered code in the public error-code ledger (packages/spec/src/api/error-code-ledger.zod.ts:117), and on MySQL deployments every unique-constraint conflict returns 500 INTERNAL_ERROR instead. A client cannot tell "this value already exists" from a server fault. The hole is invisible on SQLite and Postgres because their messages happen to hit the substrings, which is precisely why it survived. Boarding it; veto welcome.

    Scope boundary — the queued item is the shared predicate + the MySQL 409 mapping. The body's second half (a structured export carrying the conflicting column name) is a new contract surface, not a bug fix, and is deliberately not in this scope. That is also what #5495's autonumber retry branch is waiting on; a cross-link is going onto #5495 rather than expanding this issue, so the two stay one-thing-one-entry.

    Dedup. No other open issue or PR in the three repos owns unique-violation discrimination; #5495 (pm:queue, domain:engine-core, target:v17) references it only as a dependency. #5841 is the landed precedent for collapsing a dialect word-list into one named predicate — same shape, different predicate.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    Claim: PM loop round 2 (domain:cli seat, 2026-08-08 shift)
    Session: session_017uFVNMmTxLpmfQYiuKM1Yx
    Branch: claude/issue-6250-unique-violation-predicate
    Worktree: objectstack-issue-6250
    Domain: domain:cli
    File surface: packages/types/src/** (the shared predicate's landing site) + packages/rest/src/rest-server.ts + packages/rest/src/import-runner.ts + their tests + one .changeset/*.md. ⛔ EXPLICITLY EXCLUDED — other lanes' consumers: packages/services/service-messaging/** (domain:services) and packages/drivers/driver-sql/** (domain:drivers). See the scope ruling below. (Stop on breach; explain in the report.)
    Serial constraints cleared: no dev in flight in this lane; no other issue in this batch touches packages/types or packages/rest; no open PR touches rest-server.ts or import-runner.ts. Container sizing: M, mode:subagent shared container.

    Scope ruling — this card ships the user-visible half and stays in one lane. The full convergence named in the body spans four packages across three domains (types/rest here, service-messaging in domain:services, driver-sql in domain:drivers), which would make it a cross-domain single PR — the exception path, requiring a triage-seat designation this card does not carry. It does not need to be one PR: the defect a user hits today is MySQL unique violations returning 500 INTERNAL_ERROR instead of the contract's 409 UNIQUE_VIOLATION, and that is fixed entirely within this lane by introducing the named shared predicate and routing the REST mapping through it. Migrating the two out-of-lane consumers onto the same predicate is a follow-up filed per lane once the predicate exists — that is sequencing, which is the PM's call, and it keeps each seat editing its own packages.

    Out of scope, and deliberately so: the structured conflict-column export the body raises. That is a new contract surface, the body itself says it should not be decided by a bug-fix card, and the 409 mapping does not need it. #5495's autonumber-retry branch does need it — so if this seat's fix makes that shape newly cheap, say so in the report and it becomes its own card rather than a rider.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions