Found while implementing #14902 (PR #15477), which brought the DIRECT arm of syncDeclaredIndexes's catch to parity: a plain unique index over existing duplicate rows now logs on the durability channel and lets the boot continue, instead of throwing the database's raw error. The hash-shadow arm, one branch above it in the same catch, still carries the guard that was moved.
⚠️ Derived by reading the code, not measured — see "Why this was not fixed in #15477" below. Treat the reachability claim as an argument, not a reading, until someone drives it on a live MySQL.
Where
packages/drivers/driver-sql/src/sql-driver.ts, inside syncDeclaredIndexes, in the if (unkeyable) block that handles MySQL's refusal of a TEXT/BLOB key part (#11627's hash-shadow route, made NULL-safe-aware by #12998):
} catch (shadowErr: any) {
...
if (nullSafe.size > 0 && isUniqueViolationError(shadowErr)) {
// …log at `error`, name the conflicting groups, continue…
}
// falls through to the named refusal, which THROWS
The sibling direct arm below it no longer asks nullSafe.size > 0 — it asks unique && isUniqueViolationError(e) — so the two arms of the same catch now disagree about the same question.
The shape
On MySQL, a plain unique index (tenancy: { enabled: false }, or an explicit unique: 'global') over a column whose declared width exceeds the keyable ceiling takes the shadow route. The shadow ALTER computes the generated column for the EXISTING rows, so a table that already holds duplicates fails there with a uniqueness violation. With nullSafe.size at zero the branch does not fire, the code falls through to logDurabilityFailure(unkeyable, msg) and then throw, and the boot dies — carrying a message about an unkeyable text column rather than about the duplicate rows, so it names neither the conflicting rows nor a remedy.
That is exactly the shape #14902 graded p1 on the direct arm, minus the os migrate plan half: the D4 pre-flight fix in #15477 is guard-free, so os migrate plan already reports this op destructive with the row report. What is left is only the fatal, unactionable boot.
Why this was not fixed in #15477
The branch is reachable only on a live MySQL with a key part over the 768-char ceiling. This package's unit suite boots SQLite only; the hash-shadow tests (sql-driver-11627-hash-shadow-key.test.ts, sql-driver-12998-shadow-null-safe-key.test.ts) are opt-in live cells behind OS_TEST_MYSQL_URL, and the dispatch container has no MySQL. Shipping an unmeasurable change to a durability path is worse than a named gap, so the gap is named here instead.
The change itself looks mechanical — the correct form is fixed by the sibling arm — but it is not only a guard deletion: the branch's message says "existing rows violate the NULL-safe key (duplicates the previous void constraint admitted, #5030)", and neither clause is true of a plain unique. Nothing admitted those rows; the constraint is simply newly declared over data that does not satisfy it. So the message needs the same two-arm split #15477 gave the direct path.
Suggested shape (engine lane's call)
Take the guard to isUniqueViolationError(shadowErr) — the enclosing if (unique) already supplies the uniqueness limb — and branch the message the way the direct arm does. Verify on a live MySQL cell (declareDialectCell(MYSQL_CELL, …)) with a maxLength over the keyable ceiling and two duplicate rows: the boot must survive, the durability log must name the conflicting group and the remedy, and the index must be absent afterwards.
Refs: #14902, PR #15477, #11627, #12998, #5030, ADR-0120 D4.
Found while implementing #14902 (PR #15477), which brought the DIRECT arm of
syncDeclaredIndexes'scatchto parity: a plain unique index over existing duplicate rows now logs on the durability channel and lets the boot continue, instead of throwing the database's raw error. The hash-shadow arm, one branch above it in the samecatch, still carries the guard that was moved.Where
packages/drivers/driver-sql/src/sql-driver.ts, insidesyncDeclaredIndexes, in theif (unkeyable)block that handles MySQL's refusal of a TEXT/BLOB key part (#11627's hash-shadow route, made NULL-safe-aware by #12998):The sibling direct arm below it no longer asks
nullSafe.size > 0— it asksunique && isUniqueViolationError(e)— so the two arms of the samecatchnow disagree about the same question.The shape
On MySQL, a plain unique index (
tenancy: { enabled: false }, or an explicitunique: 'global') over a column whose declared width exceeds the keyable ceiling takes the shadow route. The shadowALTERcomputes the generated column for the EXISTING rows, so a table that already holds duplicates fails there with a uniqueness violation. WithnullSafe.sizeat zero the branch does not fire, the code falls through tologDurabilityFailure(unkeyable, msg)and thenthrow, and the boot dies — carrying a message about an unkeyable text column rather than about the duplicate rows, so it names neither the conflicting rows nor a remedy.That is exactly the shape #14902 graded p1 on the direct arm, minus the
os migrate planhalf: the D4 pre-flight fix in #15477 is guard-free, soos migrate planalready reports this opdestructivewith the row report. What is left is only the fatal, unactionable boot.Why this was not fixed in #15477
The branch is reachable only on a live MySQL with a key part over the 768-char ceiling. This package's unit suite boots SQLite only; the hash-shadow tests (
sql-driver-11627-hash-shadow-key.test.ts,sql-driver-12998-shadow-null-safe-key.test.ts) are opt-in live cells behindOS_TEST_MYSQL_URL, and the dispatch container has no MySQL. Shipping an unmeasurable change to a durability path is worse than a named gap, so the gap is named here instead.The change itself looks mechanical — the correct form is fixed by the sibling arm — but it is not only a guard deletion: the branch's message says "existing rows violate the NULL-safe key (duplicates the previous void constraint admitted, #5030)", and neither clause is true of a plain unique. Nothing admitted those rows; the constraint is simply newly declared over data that does not satisfy it. So the message needs the same two-arm split #15477 gave the direct path.
Suggested shape (engine lane's call)
Take the guard to
isUniqueViolationError(shadowErr)— the enclosingif (unique)already supplies the uniqueness limb — and branch the message the way the direct arm does. Verify on a live MySQL cell (declareDialectCell(MYSQL_CELL, …)) with amaxLengthover the keyable ceiling and two duplicate rows: the boot must survive, the durability log must name the conflicting group and the remedy, and the index must be absent afterwards.Refs: #14902, PR #15477, #11627, #12998, #5030, ADR-0120 D4.