fix(session): map no-such-table to 1146 and give 1062 a MySQL-shaped message - #31
Merged
Conversation
…message Two fidelity gaps that made litewire's errors unusable to clients we do not control. SQLite's "no such table" fell through to the ER_UNKNOWN_ERROR catch-all (1105 / HY000), so a missing table was indistinguishable from any other failure. Frameworks branch on 1146 / 42S02 to tell "not migrated yet" apart from a broken query -- Doctrine raises TableNotFoundException off 1146. It now maps to 1146 / 42S02, message forwarded verbatim. Duplicate keys carried raw SQLite text under 1062. Stock MySQL drivers detect a duplicate by matching the message, not just the code, so they never recognised "UNIQUE constraint failed: users.email" and ePHPm's Laravel integration had to override isUniqueConstraintError(). The message is now MySQL-shaped -- Duplicate entry '<unknown>' for key 'users.email' (UNIQUE constraint failed: users.email) -- keeping SQLite's text so nothing is lost for debugging. The value slot says <unknown> because SQLite's message does not contain the offending value; inventing one would be a lie and omitting the field would break the shape clients match on. Two existing assertions per error_map encoded the behaviour this changes, and are updated deliberately, not to make a fix go green: - unknown_falls_back_to_1105 used "no such table: sprockets" as its stand-in for an unclassifiable error, which is exactly the string now classified. Re-fixtured onto two strings that cannot both gain a mapping, with a comment saying how to move it again if one does. It also now asserts the message survives, which it did not before. - classify_preserves_message asserted the message was byte-identical to the backend's. Narrowed to "the backend's text is present", and extended with a byte-for-byte check across five classifications that are not reshaped -- so verbatim forwarding is covered on more paths than before, not fewer. Writing the tests against a real backend rather than hand-written strings caught a bug the unit tests could not: rusqlite prefixes its message with "SQLite error: ", so splitting on the first ": " named the key "UNIQUE constraint failed: users.email". The column list is now located by the "constraint failed: " marker. Fixes #22
luthermonson
added a commit
that referenced
this pull request
Sep 1, 2026
…message (#31) Two fidelity gaps that made litewire's errors unusable to clients we do not control. SQLite's "no such table" fell through to the ER_UNKNOWN_ERROR catch-all (1105 / HY000), so a missing table was indistinguishable from any other failure. Frameworks branch on 1146 / 42S02 to tell "not migrated yet" apart from a broken query -- Doctrine raises TableNotFoundException off 1146. It now maps to 1146 / 42S02, message forwarded verbatim. Duplicate keys carried raw SQLite text under 1062. Stock MySQL drivers detect a duplicate by matching the message, not just the code, so they never recognised "UNIQUE constraint failed: users.email" and ePHPm's Laravel integration had to override isUniqueConstraintError(). The message is now MySQL-shaped -- Duplicate entry '<unknown>' for key 'users.email' (UNIQUE constraint failed: users.email) -- keeping SQLite's text so nothing is lost for debugging. The value slot says <unknown> because SQLite's message does not contain the offending value; inventing one would be a lie and omitting the field would break the shape clients match on. Two existing assertions per error_map encoded the behaviour this changes, and are updated deliberately, not to make a fix go green: - unknown_falls_back_to_1105 used "no such table: sprockets" as its stand-in for an unclassifiable error, which is exactly the string now classified. Re-fixtured onto two strings that cannot both gain a mapping, with a comment saying how to move it again if one does. It also now asserts the message survives, which it did not before. - classify_preserves_message asserted the message was byte-identical to the backend's. Narrowed to "the backend's text is present", and extended with a byte-for-byte check across five classifications that are not reshaped -- so verbatim forwarding is covered on more paths than before, not fewer. Writing the tests against a real backend rather than hand-written strings caught a bug the unit tests could not: rusqlite prefixes its message with "SQLite error: ", so splitting on the first ": " named the key "UNIQUE constraint failed: users.email". The column list is now located by the "constraint failed: " marker. Fixes #22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two fidelity gaps in
litewire-session's error map, both of which made litewire's errors unusable to client code litewire does not control and cannot patch.1.
no such table→ 1146 / 42S02SQLite's
no such table: Xfell through to theER_UNKNOWN_ERRORcatch-all (1105 / HY000), which carries no information at all — a missing table looked exactly like every other failure. MySQL clients expect 1146 with SQLSTATE42S02, and frameworks branch on it to tell "the schema is not migrated yet" apart from "this query is broken": Doctrine raisesTableNotFoundExceptionoff 1146, and Laravel's schema tooling keys off42S02.The message is forwarded verbatim. SQLite already names the table, and unlike the duplicate-key case there is no MySQL message shape clients match on here.
Matching is on the substring, so it also catches the Turso backend's wording, which prefixes the same text with
Parse error:.No change was needed in
litewire-mysql: it delegates to the session classifier and converts the numeric code, andopensrv-mysql'sErrorKind::ER_NO_SUCH_TABLEalready carries42S02. There is a test pinning that down, because the SQLSTATE in the wire packet comes from that table rather than from the SQLSTATE litewire maps, and the two have to agree.2. A MySQL-shaped message for 1062
Duplicate keys were already 1062 / 23000, but carried SQLite's raw text. Stock MySQL drivers detect a duplicate by matching the message, not just the code, so
UNIQUE constraint failed: users.emailnever matched anything and ePHPm's Laravel integration had to overrideisUniqueConstraintError()to work around it.The message is now:
The synthesised part is a prefix, not a replacement — SQLite's own words are kept in parentheses, so nobody loses debugging information.
Two imprecisions, both unavoidable at this layer and both documented at the call site:
<unknown>.A message with no column list after the marker is left exactly as it was: with nothing to put in the
for keyslot, a content-freeDuplicate entry '<unknown>' for key '<unknown>'would be strictly worse than the original text.Existing assertions updated — why each is a fixture correction
Both error maps had the same two tests, and both encoded precisely the behaviour this issue asks us to change.
unknown_falls_back_to_1105used"no such table: sprockets"as its stand-in for an unclassifiable error — the exact string now deliberately classified as 1146. Re-fixtured onto"disk I/O error"(a real SQLite error with no MySQL analogue) and"something the classifier never heard of"(not a SQLite error at all). Two independent reasons for the fallback to be reached, so the test cannot quietly go vacuous if one of them ever gains a mapping, with a comment saying to move the fixture rather than delete the test ifdisk I/O errordoes. The session version also now asserts the message survives, which it did not before.classify_preserves_messageasserted the mapped message was byte-identical to the backend's. Narrowed to "the backend's text is present", since 1062 now carries a prefix — the property that matters, that SQLite's words reach the operator, is still asserted. To make sure nothing was lost, the same test now also does the strict byte-for-byte check across five classifications that are not reshaped (database is locked,FOREIGN KEY constraint failed,attempt to write a readonly database,no such table: …,disk I/O error). Verbatim forwarding is covered on more paths than before, not fewer.litewire-postgreshas its own independentclassifyand was not touched; its tests are unaffected.The real-backend tests caught a bug the string tests could not
Writing the session-level tests against
Rusqlite::memory()rather than hand-written strings immediately failed:rusqlite prefixes its message with
SQLite error:, so splitting on the first": "grabbed the wrapper's colon and named the key after the whole constraint message. The column list is now located by theconstraint failed:marker instead, which is immune to wrappers on either side. This is exactly the failure mode the unit tests could not see, since they feed the classifier the idealised string.Testing
cargo test --workspaceandcargo test --workspace --all-featurespass.cargo clippy --workspace --all-targets --all-features -- -D warningsclean;cargo +nightly fmt --all -- --checkclean.litewire-session/src/error_map.rs: 1146 for the bare andParse error:-prefixed wordings; the exact 1062 message; the message matching the marker sequence stock drivers scan for; primary-key violations taking the same shape; a composite constraint using the whole column list; a backend prefix not corrupting the key; and a message with no column list keeping its original text.litewire-mysql/src/error_map.rs: the wireErrorKindfor 1146 and thatErrorKind::sqlstate()agrees with the mapped SQLSTATE, plus the reshaped 1062 message reaching the adapter.crates/litewire-session/tests/error_fidelity.rs— 6 tests running statements that genuinely fail against a real in-memory rusqlite backend: missing table onSELECT/INSERT/UPDATE/DELETE, an existing table never being misreported as 1146, real unique and primary-key collisions producing the MySQL shape with SQLite's text retained, and unreshaped errors (both classified and fallback) keeping SQLite's wording exactly. These also pin down the message text SQLite actually emits, which is what the substring matching depends on and the thing most likely to drift on a rusqlite upgrade.crates/litewire/tests/mysql_error_codes.rs— 3 tests asserting on the error packet a realmysql_asyncclient receives: 1146/42S02for a missing table (and the connection still usable afterwards), 1062/23000with the MySQL-shaped message, and a reconstruction of the exception string PDO would build, checked against bothIntegrity constraint violation: 1062and theDuplicate entry ... for key ...pattern.Fixes #22