Repository navigation
Description
Filed by the #6544 developer agent while migrating import-runner.ts onto @objectstack/types' shared predicate. Out of that card's lane, so recorded rather than fixed.
What
packages/metadata-protocol/src/migrations/view-definition-active-index.ts:279:
export function classifyIndexFailure(message: string): EnsureViewIndexStatus {
if (/unique constraint failed|duplicate entry|duplicate key value|violates unique/i.test(message)) {
return 'conflict';
}
...
}That first arm is the same question isUniqueViolationError (packages/types/src/unique-violation.ts, #6250 / PR #6541) exists to answer once. #6250 inventoried four private vocabularies — service-messaging, rest's mapDataError, rest's sanitizeRowError, driver-sql's inline regex (#6543). This is a fifth, in a package none of those touched, so it was never in the table and is not covered by any of the queued follow-ups.
Why it is observation-class, not a live defect
The four message substrings here are a superset of the shared predicate's message limb minus nothing that matters — all three dialects emit one of these words on a duplicate-key CREATE UNIQUE INDEX failure, so today's callers get the right verdict.
The gap is the one #6250 was about: this reads the message channel only. A driver that reports the conflict on code / errno and gives unhelpful prose (insert failed, a pooled wrapper's Write failed, or the condition on error.cause) is classified failed here, where the shared predicate answers true. That is exactly the hole that made every MySQL conflict a 500 in mapDataError before #6541 — the same shape, in a spot nobody has measured.
Consequence if it ever lands: a real duplicate-row conflict on sys_view_definition reports as failed rather than conflict, which (per the function's own doc comment, and more so since #6417 made the data-conflict path live) is precisely the misreport the ordering in that comment was written to prevent.
Not a mechanical swap
Two things a migration has to keep, both documented in place:
- The ordering is load-bearing. Duplicate-row wording is deliberately checked BEFORE dialect wording, because MySQL's duplicate error mentions the key and some drivers wrap both facts in one string.
isUniqueViolationErroranswers a boolean and has no opinion about the second arm, so the call has to stay first, not merge. - This function takes a
message: string, not the error. The predicate accepts a bare string, so the swap compiles unchanged — but a string-only call throws away the verycode/errno/causechannels that are the reason to migrate. Doing this properly means passing the error object down from the caller, which is the actual work.
Suggested shape
Pass the caught error into classifyIndexFailure (keeping a string overload if a caller genuinely has only prose), call isUniqueViolationError(error) for the first arm, and leave the dialect arm as-is. Sibling to #6543, and the same lane discipline: one consumer per PR.
Related: #6250 (the predicate), #6543 (driver-sql's copy), #6544 (import-runner's copy), #6417 / #5839 (what made this path live).
Activity
os-zhuang commentedon Aug 8, 2026
Findings-triage round (#4949 discipline): promoted —
finding→pm:queue,domain:metadatakept.origin/main@3172831): premise holds —classifyIndexFailureatpackages/metadata-protocol/src/migrations/view-definition-active-index.ts:278still takesmessage: stringonly and still answers the unique-violation question with its own private regex at:279. The shared predicate (packages/types/src/unique-violation.ts) exists and is unconsumed here.Duplicate entry落成 500 INTERNAL_ERROR) #6250 convergence program, not a standalone observation — sibling driver-sql's inline unique-violation regex is a fourth private vocabulary — migrate it onto the shared @objectstack/types predicate #6543 (driver-sql's copy) sits queued indomain:drivers, and import-runner's sanitizeRowError keeps its own three-dialect unique regexes — deferred from #6250 to avoid a merge race with #4633 #6544 (import-runner's copy) already landed. The fix shape is bounded and stated in the body (pass the caught error down,isUniqueViolationError(error)for the first arm only, ordering preserved), one consumer per PR per the family's lane discipline. Leaving the fifth vocabulary out of the program it was inventoried against is drift, not thrift.:369) — a string-only swap discards thecode/errno/causechannels that justify the migration.Duplicate entry落成 500 INTERNAL_ERROR) #6250 / driver-sql's inline unique-violation regex is a fourth private vocabulary — migrate it onto the shared @objectstack/types predicate #6543 / import-runner's sanitizeRowError keeps its own three-dialect unique regexes — deferred from #6250 to avoid a merge race with #4633 #6544 are the family, each a distinct consumer; no in-flight PR touchesview-definition-active-index.ts(checked open PRs). No serial constraint.target:*— the message superset covers all three shipped dialects today; the hole needs a code/errno-only driver report to land.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 8, 2026
Claim: PM loop round 5 (
⚠️ Base ≥
domain:metadataseat, sticker #6367)Session:
session_01W6bLax4KMrSfnE1ydFU8DwBranch:
claude/issue-6699-classify-index-failure-error-channelWorktree:
objectstack-issue-6699Domain:
domain:metadataFile surface:
packages/metadata-protocol/src/migrations/view-definition-active-index.ts(classifyIndexFailure+ the caller that threads the error object down) +view-definition-active-index.test.ts+ changeset.packages/types'isUniqueViolationErroris consumed read-only — if its contract turns out insufficient for the first arm, that is a STOP → report, not an edit.50a8d115a— PR #6817 (#6772) landed tonight in this same file (thebuildDuplicateProbeSqlregion), so line anchors in the card body have moved; branch from freshorigin/main.Binding constraints from the triage promotion (2026-08-08 14:29Z): arm ordering stays (duplicate/unique wording checked BEFORE dialect wording); the real work is threading the caught error object from the caller — a string-only swap discards the
code/errno/causechannels that justify the migration;isUniqueViolationError(error)answers the FIRST arm only, the dialect arm stays as-is. One consumer per PR (family: #6250 / #6543 / #6544).Generated by Claude Code
os-zhuang commentedon Aug 9, 2026
os-dev report — #6699 · status
done· PR #6845 (draft) · branchclaude/issue-6699-classify-index-failure-error-channelPremise: valid, but the card's file anchor had expired
The issue body and the 14:29Z triage comment both place
classifyIndexFailureatview-definition-active-index.ts:279. Onorigin/main@220536377it is not there — #6418 moved it tomigrations/partial-index-probe.ts:102whenensureOverlayIndexadopted the probe-first order;view-definition-active-index.ts:196only re-exports it now. This is a bigger drift than the claim comment anticipated (it warned about line anchors moving under PR #6817, not about the function having changed file).Everything the finding asserts holds at the new address: still
classifyIndexFailure(message: string), still its own private regex, andisUniqueViolationErrorstill had zero consumers in this package. Sopremise_still_valid: true— the fix simply landed one file over.Two consequences of the move, both recorded in the PR:
view-definition-active-index.tsandoverlay-index.tsclassify through the oneprobeThenReplaceIndexcall site, so there is exactly one threading point and both migrations are fixed by it. Still one consumer in the 唯一约束冲突没有单一判别谓词:仓内四套各自为政的方言词表,REST 的 409 映射漏掉 MySQL(Duplicate entry落成 500 INTERNAL_ERROR) #6250 family sense — one vocabulary, one PR.overlay-index.tsACTS on the verdict, it does not only report it: itsunsupportedbranch builds a composite fallback lookup index. A conflict misclassified asunsupportedtherefore also built an index nobody asked for.PM mechanism assumptions — measured
50a8d115a220536377classifyIndexFailurestring-only, predicate unconsumed here:369) + testsprobeThenReplaceIndex'scatchinpartial-index-probe.ts, reached by both migrations; plus theindex.tspublic re-export and two test files. Full-repo grep in the PR.isUniqueViolationErrortakes string or object, readscode/errno/cause/messagepackages/types/src/unique-violation.ts. Named export,(error: unknown) =. boolean, cause walk bounded at depth 4. Contract sufficient — nopackages/typesedit, no STOP needed.What changed
First arm delegates to
isUniqueViolationError;probeThenReplaceIndexpasses the caught error object rather thanerr.message.detailis untouched — still the driver's prose, for the operator. Signature widensstringtounknown, so every existing string call compiles and is judged identically.Both load-bearing constraints preserved. Arm order unchanged (duplicate/unique before dialect, with the doc comment's rationale kept and extended). Dialect arm untouched and still this module's own message-based vocabulary, reading via a new private
indexFailureTextthat prefersmessageand falls back toString()— a bare string resolves to itself.Measured old-versus-new: all 6 message-channel verdicts unchanged; 6 object-channel cases move from
failed/unsupportedtoconflict(SQLiteSQLITE_CONSTRAINT_UNIQUE, MySQLER_DUP_ENTRY/errno 1062, bare errno, Postgres23505, acause-wrapped23505, and code-plus-dialect-wording). Full table in the PR.Reverse verification — direction predicted first, split in two
Stage A, revert only the call-site threading: predicted exactly one red — the threading pin — because no classifier-level assertion can see how the caller calls it. Result:
1 failed | 754 passed,the probe hands the ERROR to the classifier→expected 'failed' to be 'conflict'.Stage B, revert the whole source file to
origin/main: predicted all object-channel pins red, all prose pins green. Result:8 failed | 747 passed, all 8 object-channel, every prose pin green.One prediction reported honestly rather than dressed up:
a dialect refusal carrying its own code is still 'unsupported'is green in both directions by design — it pins that widening the input did not blind the second arm, so it can never be evidence the fix works.Tests and gates
pnpm --filter @objectstack/metadata-protocol test→ 63 files, 755 tests, all pass.tsc --noEmit→ 0 errors in the changed files; package total 63, exactly its DEBT ledger entry, so the ratchet does not move.eslintclean.check:nul-bytes,check:durability-log-level,check:adr-anchors,check:doc-authoring,check:empty-changeset,check:startup-registry-verdict,check:error-code-casing,check:type-check-coverageall pass.check:type-check-debtrefuses to run locally without a full build closure (its own #6376 guard) and was left to CI.CI converged, 24 check runs, 22 success + 2 skipped, 0 failures. Per-job conclusions read individually:
ESLintsuccess,TypeScript Type Checksuccess (this is where the debt ratchet ran with the closure built),Check Changesetsuccess,Test Coreand all three shards success.Changeset:
@objectstack/metadata-protocolpatch.skip-changesetnot applicable — a changeset is present.Out of scope
Filed #6848 (
finding, unassigned, nopm:queue): the two arms now read different depths —isUniqueViolationErrorwalkscause, the dialect arm reads the outer message only. Dormant (no shipped driver wraps DDL errors that way) and not a regression from this PR, but if a wrapping driver ever lands,overlay-index.tssilently skips its fallback lookup index rather than merely wording the report differently.Generated by Claude Code
Generated by Claude Code