Found while measuring #15041 (the FILE_REFERENCE_TYPES column fork). Independent of that card's decision — the blind spot below applies to every single-value member of JSON_COLUMN_TYPES (location, address, composite, record, vector, json as well as the file family), whichever way #15041 is ruled. Filed unassigned; no severity asserted.
The gap
Measured on origin/main 8e500f23e (2026-09-05T06:58Z).
packages/drivers/driver-sql/src/schema-drift.ts:944:
const declaresJsonColumn = field.multiple === true;
if (declaresJsonColumn && multiValueColumnTypeIsLoadBearing(dialect) && acceptsStringifiedJson(col.type)) {
out.push({ kind: 'type_mismatch', ..., expected: 'json', actual: col.type, ... });
The type_mismatch finding that reports "metadata declares a JSON column but the database has a char/text one" is keyed to multiple alone (#11535 / #11733 — the single-to-multi change). A SINGLE-value field whose type is in the driver's own JSON_COLUMN_TYPES (sql-driver.ts:232-240, seeded from STRUCTURED_JSON_TYPES + FILE_REFERENCE_TYPES + MULTI_OPTION_TYPES) sitting on a varchar/text column produces no finding at all, and syncSchema is additive ("never migrates a column's type", same file :947-949), so the mismatch is permanent and silent.
Why it is reachable today
packages/cli/src/commands/generate.ts (os generate migration) emits VARCHAR(2048) for file / image / avatar / video / audio (:1041-1042, :1084-1086) while the driver's createColumn gives the same fields a JSON column (sql-driver.ts:16066 catch-all, jsonColumn :15752 = table.json off SQLite). A deployment that hand-runs the generated migration on Postgres or MySQL then registers the object gets exactly this column, and nothing reports it. (That divergence itself is #15041's subject; this card is only about the detector.)
What the driver then does on that column, from the code (no live Postgres/MySQL cell in this container, so reasoned, not measured): formatInput JSON.stringifys every isJsonField value unconditionally (sql-driver.ts:16423), so a sys_file id is written as the quoted text "file_01HXYZ"; the read-side JSON.parse lives only inside the if (this.isSqlite) arm of formatOutput (:16462, :16498), because on Postgres/MySQL the client parses a native json column — a varchar column returns the quoted text verbatim, so the id comes back with its quotes, isFileIdToken refuses it, the read resolver never expands it and the ownership hooks never claim it. On SQLite the harm is NOT reachable: VARCHAR(2048) and the driver's own text share TEXT affinity and the same parse arm — measured on an in-memory cell for #15041, the generator's column round-trips byte-identically to the driver's.
What a fix looks like
Extend declaresJsonColumn to field.multiple === true || JSON_COLUMN_TYPES.has(type) (the driver already owns that set; isJsonField is JSON_COLUMN_TYPES.has(type) || !!field.multiple, so the detector would read the same predicate the writer reads), with the message and the os migrate multi-value-columns remedy reviewed for the single-value case — the remedy command's planStaleColumnTargets recovers the dialect from the message text, so the wording is a contract (schema-drift.ts:925-943). Pin it in schema-drift.base-type-mismatch.test.ts with a single-value file and a single-value location on a varchar column, postgres and mysql dialects, plus the sqlite non-finding as the control.
Generated by Claude Code
Found while measuring #15041 (the FILE_REFERENCE_TYPES column fork). Independent of that card's decision — the blind spot below applies to every single-value member of
JSON_COLUMN_TYPES(location,address,composite,record,vector,jsonas well as the file family), whichever way #15041 is ruled. Filed unassigned; no severity asserted.The gap
Measured on
origin/main8e500f23e(2026-09-05T06:58Z).packages/drivers/driver-sql/src/schema-drift.ts:944:The
type_mismatchfinding that reports "metadata declares a JSON column but the database has a char/text one" is keyed tomultiplealone (#11535 / #11733 — the single-to-multi change). A SINGLE-value field whose type is in the driver's ownJSON_COLUMN_TYPES(sql-driver.ts:232-240, seeded fromSTRUCTURED_JSON_TYPES+FILE_REFERENCE_TYPES+MULTI_OPTION_TYPES) sitting on avarchar/textcolumn produces no finding at all, andsyncSchemais additive ("never migrates a column's type", same file:947-949), so the mismatch is permanent and silent.Why it is reachable today
packages/cli/src/commands/generate.ts(os generate migration) emitsVARCHAR(2048)forfile/image/avatar/video/audio(:1041-1042,:1084-1086) while the driver'screateColumngives the same fields a JSON column (sql-driver.ts:16066catch-all,jsonColumn:15752=table.jsonoff SQLite). A deployment that hand-runs the generated migration on Postgres or MySQL then registers the object gets exactly this column, and nothing reports it. (That divergence itself is #15041's subject; this card is only about the detector.)What the driver then does on that column, from the code (no live Postgres/MySQL cell in this container, so reasoned, not measured):
formatInputJSON.stringifys everyisJsonFieldvalue unconditionally (sql-driver.ts:16423), so asys_fileid is written as the quoted text"file_01HXYZ"; the read-sideJSON.parselives only inside theif (this.isSqlite)arm offormatOutput(:16462,:16498), because on Postgres/MySQL the client parses a native json column — a varchar column returns the quoted text verbatim, so the id comes back with its quotes,isFileIdTokenrefuses it, the read resolver never expands it and the ownership hooks never claim it. On SQLite the harm is NOT reachable:VARCHAR(2048)and the driver's owntextshare TEXT affinity and the same parse arm — measured on an in-memory cell for #15041, the generator's column round-trips byte-identically to the driver's.What a fix looks like
Extend
declaresJsonColumntofield.multiple === true || JSON_COLUMN_TYPES.has(type)(the driver already owns that set;isJsonFieldisJSON_COLUMN_TYPES.has(type) || !!field.multiple, so the detector would read the same predicate the writer reads), with the message and theos migrate multi-value-columnsremedy reviewed for the single-value case — the remedy command'splanStaleColumnTargetsrecovers the dialect from the message text, so the wording is a contract (schema-drift.ts:925-943). Pin it inschema-drift.base-type-mismatch.test.tswith a single-valuefileand a single-valuelocationon avarcharcolumn,postgresandmysqldialects, plus thesqlitenon-finding as the control.Generated by Claude Code