Skip to content

Commit 53aeb02

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): classify a failed index build from the ERROR, not its message (#6699) (#6845)
`classifyIndexFailure` carried a fifth private unique-violation vocabulary — the one #6250's inventory missed, because it lives in a package none of the other four touched — and answered from the message channel only. The first arm now delegates to `@objectstack/types`' `isUniqueViolationError`, and `probeThenReplaceIndex` passes it the caught ERROR OBJECT rather than `err.message`. A string-only swap would have compiled unchanged and kept the defect: the `code` / `errno` / `cause` channels are the point of the shared predicate. A conflict reported on `code`/`errno` with unhelpful prose (SQLite's `SQLITE_CONSTRAINT_UNIQUE`, MySQL's `ER_DUP_ENTRY`/1062, Postgres' `23505`, or one step down `cause`) was classified `failed`; it is now `conflict`, which is the verdict that produces ADR-0120 D4's report. Every message-channel verdict is unchanged. Preserved deliberately: the arm order (duplicate wording judged BEFORE dialect wording, since MySQL's duplicate error mentions the key and some drivers wrap both facts in one string), and the dialect arm as this module's own message-based vocabulary — the shared predicate answers the first arm only. Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2465133 commit 53aeb02

4 files changed

Lines changed: 193 additions & 5 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): classify a failed index build from the ERROR, not its message (#6699)
6+
7+
`classifyIndexFailure` — the function both runtime partial-index migrations in
8+
this package classify a failed `CREATE UNIQUE INDEX` with — carried its own
9+
private unique-violation vocabulary and answered from the **message channel
10+
only**. That made it the fifth such copy in the repo, and the one #6250's
11+
inventory missed: it lives in a package none of the other four touched, so it
12+
was never in that table and none of the queued follow-ups covered it.
13+
14+
The first arm now delegates to `@objectstack/types`' `isUniqueViolationError`
15+
(#6250 / PR #6541) — the one named answer to "is this a unique-constraint
16+
violation?" — and `probeThenReplaceIndex` passes it the **caught error object**
17+
instead of `err.message`. A string-only swap would have compiled unchanged and
18+
kept the defect: the point of the shared predicate is the `code` / `errno` /
19+
`cause` channels, which unwrapping the message throws away.
20+
21+
**What changes at runtime.** A driver that reports the conflict on `code` or
22+
`errno` while giving unhelpful prose — SQLite's `SQLITE_CONSTRAINT_UNIQUE`,
23+
MySQL's `ER_DUP_ENTRY` / errno `1062`, Postgres' SQLSTATE `23505`, or the
24+
condition one step down `error.cause` behind a pooled wrapper's `Write failed`
25+
— was classified `failed`. It is now `conflict`, which is the verdict that
26+
produces the report ADR-0120 D4 requires: the key that is not enforced, the
27+
query that lists the offending rows, and the pointer at `os migrate plan`.
28+
Every message-channel verdict is unchanged — the shared predicate's message
29+
limb covers all three shipped dialects' prose.
30+
31+
**Two things deliberately preserved.** The arm order still checks the
32+
duplicate-row question BEFORE the dialect question, because MySQL's duplicate
33+
error mentions the key and some drivers wrap both facts in one string; and the
34+
dialect arm (`unsupported`) is still this module's own message-based
35+
vocabulary, since the shared predicate answers the first arm only and has no
36+
opinion about dialect support.
37+
38+
`classifyIndexFailure`'s parameter widens from `string` to `unknown`, so every
39+
existing string call still compiles and is judged exactly as before. Callers
40+
holding a caught error should pass it directly rather than `err.message`.

packages/metadata-protocol/src/migrations/partial-index-probe.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,97 @@ describe('probe-first partial index replacement (#6418)', () => {
157157
expect(classifyIndexFailure('disk I/O error')).toBe('failed');
158158
});
159159

160+
/**
161+
* #6699 — the substance of the migration onto `@objectstack/types`'
162+
* `isUniqueViolationError`: the conflict is judged on the channels the
163+
* driver actually wrote it to, not on prose alone.
164+
*
165+
* Every message below is deliberately USELESS — none carries a word any
166+
* unique-violation vocabulary's message limb looks for — so each case can
167+
* pass ONLY by reading `code` / `errno`. The second assertion in each case
168+
* proves that: run the same prose through the classifier on its own and the
169+
* verdict is `failed`, which is what this module answered for all of them
170+
* while it carried its own message-only regex.
171+
*/
172+
it.each([
173+
['a SQLite extended result code', { code: 'SQLITE_CONSTRAINT_UNIQUE' }],
174+
["mysql2's symbolic name", { code: 'ER_DUP_ENTRY' }],
175+
['a bare MySQL errno', { errno: 1062 }],
176+
['a Postgres SQLSTATE', { code: '23505' }],
177+
])('reads a conflict off %s when the message says nothing (#6699)', (_label, channels) => {
178+
const error = Object.assign(new Error('insert failed'), channels);
179+
expect(classifyIndexFailure(error)).toBe('conflict');
180+
expect(classifyIndexFailure(error.message)).toBe('failed');
181+
});
182+
183+
it('follows a pooled wrapper down to the cause (#6699)', () => {
184+
// Pool and query-builder layers re-throw with the original attached, so
185+
// the only copy of the condition is one step down.
186+
const wrapped = Object.assign(new Error('Write failed'), {
187+
cause: Object.assign(new Error('insert failed'), { code: '23505' }),
188+
});
189+
expect(classifyIndexFailure(wrapped)).toBe('conflict');
190+
expect(classifyIndexFailure(wrapped.message)).toBe('failed');
191+
});
192+
193+
it('keeps the data verdict ahead of the dialect verdict on the OBJECT channel too (#6699)', () => {
194+
// The arm order, re-pinned where widening the input could have broken
195+
// it: a duplicate reported on `code`, wrapped by a layer whose prose is
196+
// a dialect refusal. Judged on the message alone this is `unsupported`
197+
// — "this database cannot build this index" for a real data conflict,
198+
// exactly the misreport the ordering exists to prevent.
199+
const both = Object.assign(new Error('near "WHERE": syntax error'), {
200+
code: 'SQLITE_CONSTRAINT_UNIQUE',
201+
});
202+
expect(classifyIndexFailure(both)).toBe('conflict');
203+
expect(classifyIndexFailure(both.message)).toBe('unsupported');
204+
// …and on a single string carrying both facts, unchanged since #6418.
205+
expect(
206+
classifyIndexFailure('near "WHERE": syntax error — duplicate key value violates unique constraint'),
207+
).toBe('conflict');
208+
});
209+
210+
it('a dialect refusal carrying its own code is still `unsupported` (#6699)', () => {
211+
// Widening the input from `string` to the error object must not blind
212+
// the second arm: MySQL's parse error has `code` and `errno` too, and
213+
// neither is a unique-violation signal, so the verdict has to come from
214+
// the message exactly as before.
215+
const parseError = Object.assign(
216+
new Error("You have an error in your SQL syntax ... near 'WHERE state'"),
217+
{ code: 'ER_PARSE_ERROR', errno: 1064 },
218+
);
219+
expect(classifyIndexFailure(parseError)).toBe('unsupported');
220+
const io = Object.assign(new Error('disk I/O error'), { code: 'SQLITE_IOERR', errno: 10 });
221+
expect(classifyIndexFailure(io)).toBe('failed');
222+
});
223+
224+
it('the probe hands the ERROR to the classifier, not its message (#6699)', async () => {
225+
// The threading pin, and the only test here that can see it: every
226+
// assertion above still passes if `probeThenReplaceIndex` keeps
227+
// unwrapping `err.message` before classifying. This one cannot — the
228+
// verdict exists nowhere but on `code`.
229+
const codeOnly: IndexExec = async (sql: string) => {
230+
if (sql.startsWith('CREATE')) {
231+
throw Object.assign(new Error('insert failed'), { code: 'SQLITE_CONSTRAINT_UNIQUE' });
232+
}
233+
return db.exec(sql);
234+
};
235+
236+
const outcome = await probeThenReplaceIndex(codeOnly, {
237+
indexName: REAL,
238+
probeIndexName: PROBE,
239+
buildSql,
240+
});
241+
242+
expect(outcome.status).toBe('conflict');
243+
expect(outcome.failedAt).toBe('probe');
244+
// `detail` is unchanged — still the driver's own prose, for the operator.
245+
expect(outcome.detail).toBe('insert failed');
246+
// The probe is what failed, so the previous index is untouched.
247+
expect(indexDdl(REAL)).toEqual(EXISTING_DDL);
248+
expect(indexDdl(PROBE)).toBeUndefined();
249+
});
250+
160251
it('logProblem prefers error(), falls back to warn(), and tolerates neither', () => {
161252
const full = { warn: vi.fn(), error: vi.fn() };
162253
logProblem(full, 'msg', 'detail');

packages/metadata-protocol/src/migrations/partial-index-probe.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
* a classified status plus the driver's own text and stays out of the way.
3838
*/
3939

40+
import { isUniqueViolationError } from '@objectstack/types';
41+
4042
/** Raw-SQL seam. Mirrors `ensureOverlayIndex`: `raw()` first, `execute()` second. */
4143
export type IndexExec = (sql: string) => Promise<unknown>;
4244

@@ -86,7 +88,26 @@ export type PartialIndexStatus =
8688
| 'failed';
8789

8890
/**
89-
* Classify a failed `CREATE UNIQUE INDEX`.
91+
* The text the DIALECT arm judges, from a thrown value of any shape.
92+
*
93+
* `message` first, because that is the channel a driver writes its refusal on
94+
* and the only one this arm has ever read; `String()` only as the last resort
95+
* — which is what a bare string resolves to unchanged, so a caller holding
96+
* nothing but prose is judged exactly as before.
97+
*/
98+
function indexFailureText(error: unknown): string {
99+
if (typeof error === 'string') return error;
100+
if (typeof error === 'object' && error !== null) {
101+
const { message } = error as { message?: unknown };
102+
if (typeof message === 'string') return message;
103+
}
104+
return String(error);
105+
}
106+
107+
/**
108+
* Classify a failed `CREATE UNIQUE INDEX`, from the thrown ERROR (#6699).
109+
*
110+
* ## The two arms, and why the order is load-bearing
90111
*
91112
* Duplicate-row wording is checked BEFORE dialect wording: MySQL's duplicate
92113
* error mentions the key, and some drivers wrap both facts in one string, so
@@ -98,12 +119,35 @@ export type PartialIndexStatus =
98119
* split: no partial indexes at all, and (before 8.0.13 / on MariaDB) no
99120
* functional key parts for `COALESCE` parts. Both leave the same outcome — the
100121
* previous index stays — so one verdict is enough.
122+
*
123+
* ## Why the first arm is not this module's own regex any more
124+
*
125+
* "Is this a unique-constraint violation?" is one question, and #6250 gave it
126+
* one named answer — `isUniqueViolationError` in `@objectstack/types`. This
127+
* function carried a **fifth** private vocabulary for it (#6699, missed by that
128+
* inventory because it lives in a package none of the other four touched), and
129+
* the copy was strictly weaker in the way that inventory was about: it read the
130+
* **message channel only**. A driver that reports the conflict on `code` /
131+
* `errno` — SQLite's `SQLITE_CONSTRAINT_UNIQUE`, MySQL's `ER_DUP_ENTRY` /
132+
* errno `1062`, Postgres' SQLSTATE `23505` — while giving unhelpful prose
133+
* (`insert failed`, a pooled wrapper's `Write failed`, or the condition one step
134+
* down `error.cause`) was classified `failed` here, where the shared predicate
135+
* answers `true`. Same shape as the hole that made every MySQL conflict a 500
136+
* in `mapDataError` before #6541.
137+
*
138+
* The predicate answers the FIRST arm only. It has no opinion about dialect
139+
* support, so the second arm stays this module's own — and stays second.
140+
*
141+
* ⚠️ Pass the **error**, not `err.message`. A string still works (the predicate
142+
* reads it on the message channel, and so does {@link indexFailureText}), but a
143+
* caller that unwraps first throws away the `code` / `errno` / `cause` channels
144+
* that are the whole reason this reads the object.
101145
*/
102-
export function classifyIndexFailure(message: string): PartialIndexStatus {
103-
if (/unique constraint failed|duplicate entry|duplicate key value|violates unique/i.test(message)) {
146+
export function classifyIndexFailure(error: unknown): PartialIndexStatus {
147+
if (isUniqueViolationError(error)) {
104148
return 'conflict';
105149
}
106-
if (/partial|where clause|near "where"|near 'where'|functional|syntax/i.test(message)) {
150+
if (/partial|where clause|near "where"|near 'where'|functional|syntax/i.test(indexFailureText(error))) {
107151
return 'unsupported';
108152
}
109153
return 'failed';
@@ -169,9 +213,14 @@ export async function probeThenReplaceIndex(
169213
try {
170214
await exec(buildSql(probeIndexName));
171215
} catch (err: unknown) {
216+
// `detail` is the OPERATOR-facing text and stays the driver's own prose.
217+
// The VERDICT is taken from the error object itself, so a conflict
218+
// reported on `code` / `errno` / `cause` with unhelpful prose is still
219+
// classified as one (#6699) — unwrapping first is exactly what the
220+
// migration onto the shared predicate exists to stop.
172221
const detail = err instanceof Error ? err.message : String(err);
173222
await dropIndexQuietly(exec, probeIndexName);
174-
return { status: classifyIndexFailure(detail), detail, failedAt: 'probe' };
223+
return { status: classifyIndexFailure(err), detail, failedAt: 'probe' };
175224
}
176225
await dropIndexQuietly(exec, probeIndexName);
177226

packages/metadata-protocol/src/migrations/view-definition-active-index.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,14 @@ describe('sys_view_definition active-row uniqueness (#5839) on a NULL-safe key (
469469
// MariaDB's refusal of a functional key part, which #6417 introduces.
470470
expect(classifyIndexFailure('Functional index on a column is not supported')).toBe('unsupported');
471471
expect(classifyIndexFailure('disk I/O error')).toBe('failed');
472+
// #6699: the same verdict off the `code` channel, with prose that
473+
// carries no signal at all. Asserted through THIS module's re-export
474+
// (the public `@objectstack/metadata-protocol` surface), because that is
475+
// the export the classifier's own home is reached by — the full
476+
// channel matrix lives in `partial-index-probe.test.ts`.
477+
expect(
478+
classifyIndexFailure(Object.assign(new Error('insert failed'), { code: 'ER_DUP_ENTRY' })),
479+
).toBe('conflict');
472480
});
473481

474482
it('buildActiveIndexSql scopes rows AND spells the key NULL-safe', () => {

0 commit comments

Comments
 (0)