Found while fixing #10965 (PR link below), by enumerating the dialect shapes from the code rather than from that card's summary. Not fixed there — different defect class, different blast radius.
The defect
#10965 describes packages/services/service-package/src/index.ts as carrying "the same three-dialect flattener metadata-protocol exports". Read against the source, it does not. The local copy has exactly two accepting branches:
functionnormalizeRows(result: any): any[]{if(Array.isArray(result))returnresult;if(result&&Array.isArray(result.rows))returnresult.rows;return[];}metadata-protocol's copy (src/migrations/seed-tenancy-backfill.ts:362) has three — it unwraps the mysql2 tuple first:
if(Array.isArray(result)){// mysql2's `[rows, fields]`: the first element is itself the row array.if(result.length>0&&Array.isArray(result[0])){returnresult[0]asRecord<string,unknown>[];}returnresultasRecord<string,unknown>[];}| # | shape | dialect | local copy |
|---|
| 1 | bare row array | better-sqlite3 through knex, Turso | flattened |
| 2 | { rows, rowCount, … } | pg | flattened |
| 3 | [rows, fields] tuple | mysql2 | not unwrapped |
The doc comment above the local copy names three bullets, but bullets 2 and 3 are both { rows } — so the prose reads as three-dialect coverage while the code implements two. That is likely how the gap survived.
Why it matters — traced by reading, NOT reproduced
On a mysql2-backed seam, normalizeRows([[row], [fields]]) returns the whole 2-element tuple rather than the row array. get() then reads rows[0], which is the row array, so row.manifest is undefined and JSON.parse(undefined) throws — into get()'s own catch, which returns null. list() maps over the tuple and throws in the same place, into its catch, which returns [].
So the caller-visible answers are, again, "this package is not installed" and "no packages are installed" — over a driver that answered correctly with real rows.
⚠️ This is traced from the source, not reproduced: no mysql2 driver was booted, and none is loaded by any suite that exercises this file. Someone should reproduce before sizing it. It is also worth checking whether a mysql2 seam reaches this service in any supported composition at all — if it cannot, this is latent rather than live.
Why this is a different card from #10965
#10965 is a seam that could not answer being read as empty. This is a seam that answered being misread — the query ran, the rows came back, and the flattener dropped them. Different class, and the fix is a different line. #10965's fix does not touch it: its isResultSet guard correctly treats a tuple as an answer (it is an array), so no false refusal is introduced, and PR #REPLACE_PR pins exactly that non-misfire. It simply does not unwrap the tuple, because that was never in its scope.
Not measured here
Refs
#10965 · #10677 / PR #10788 · #10789 / PR #10964
Found while fixing #10965 (PR link below), by enumerating the dialect shapes from the code rather than from that card's summary. Not fixed there — different defect class, different blast radius.
The defect
#10965 describes
packages/services/service-package/src/index.tsas carrying "the same three-dialect flattenermetadata-protocolexports". Read against the source, it does not. The local copy has exactly two accepting branches:metadata-protocol's copy (src/migrations/seed-tenancy-backfill.ts:362) has three — it unwraps the mysql2 tuple first:{ rows, rowCount, … }[rows, fields]tupleThe doc comment above the local copy names three bullets, but bullets 2 and 3 are both
{ rows }— so the prose reads as three-dialect coverage while the code implements two. That is likely how the gap survived.Why it matters — traced by reading, NOT reproduced
On a mysql2-backed seam,
normalizeRows([[row], [fields]])returns the whole 2-element tuple rather than the row array.get()then readsrows[0], which is the row array, sorow.manifestisundefinedandJSON.parse(undefined)throws — intoget()'s own catch, which returnsnull.list()maps over the tuple and throws in the same place, into its catch, which returns[].So the caller-visible answers are, again, "this package is not installed" and "no packages are installed" — over a driver that answered correctly with real rows.
Why this is a different card from #10965
#10965 is a seam that could not answer being read as empty. This is a seam that answered being misread — the query ran, the rows came back, and the flattener dropped them. Different class, and the fix is a different line. #10965's fix does not touch it: its
isResultSetguard correctly treats a tuple as an answer (it is an array), so no false refusal is introduced, and PR #REPLACE_PR pins exactly that non-misfire. It simply does not unwrap the tuple, because that was never in its scope.Not measured here
objectql.executefor this service.packages/cli/src/commands/migrate/duplicates.ts:521— anisResultSet, not a flattener) has a matching gap in whatever it uses to flatten.metadata-protocol's branch into this local flattener or to unify the copies — the latter being the open question service-package answers "no such package" / "no packages installed" over a driver it never queried — its own normalizeRows maps a non-answering seam onto zero rows #10965 already raises forisResultSet, and this finding is a second data point for it.Refs
#10965 · #10677 / PR #10788 · #10789 / PR #10964