Drizzle ORM 与 Bun.SQL 的 JSONB 双重编码问题
我在给一个 JSONB 列增加嵌套对象时,遇到了一个很迷惑的现象:写入前对象结构正常,读取后对应属性却始终是 undefined,依赖这个属性的界面分支也没有出现。
一开始看起来像是应用层的条件写错了。但继续检查后发现,数据库文本里能看到目标值,服务端也没有执行过会改变它的逻辑。
真正的问题不是属性值,而是属性外面那一层 JSON 的类型。
这次项目使用的是 Drizzle ORM 0.45.x、Bun.SQL 和 PostgreSQL。一个本应保存为 JSONB 对象的嵌套字段,最终变成了 JSONB string。TypeScript 类型、接口返回和静态检查都没有发现这个差异,直到代码读取 payload.state 时才暴露出来。
值明明存在,为什么属性读不到
错误数据的结构看起来接近这样:
{
"payload": "{\"state\":\"READY\",\"values\":[\"a\",\"b\"]}"
}正确结构应该是:
{
"payload": {
"state": "READY",
"values": ["a", "b"]
}
}两者打印出来时都能看到 READY,但前者的 payload 是字符串。于是:
payload.state得到的是 undefined。问题不在属性值,而在 payload 仍然是一个字符串。
用 jsonb_typeof 直接检查存储形态
只看 JSON 内容不够,PostgreSQL 的 jsonb_typeof 才能回答它究竟是对象还是字符串。我当时用下面的只读查询同时检查两种结构:
select
id,
jsonb_typeof(metadata->'payload') as payload_type,
metadata->'payload'->>'state' as object_state,
case
when jsonb_typeof(metadata->'payload') = 'string'
then (metadata->>'payload')::jsonb->>'state'
else null
end as string_state
from records
where metadata ? 'payload'
order by updated_at desc
limit 10;异常记录返回的是:
payload_type: string
object_state: null
string_state: READY这个结果把问题缩小到了数据库写入边界:目标值没有错,嵌套对象的存储类型错了。
自定义 JSONB 类型没有覆盖原生 SQL
项目原本已经为 Drizzle 和 Bun.SQL 的组合定义了自定义 JSONB 类型:写入时不再由 Drizzle 提前 JSON.stringify,而是把对象和数组原样交给能够原生处理它们的 Bun.SQL。
export const jsonb = customType<{ data: unknown; driverData: unknown }>({
dataType() {
return 'jsonb'
},
toDriver(value) {
return value
},
fromDriver(value) {
return decodeLegacyJson(value)
},
})这层适配能保护普通的 Drizzle 列写入,但这次新增的是一段原生 SQL:
return sql`jsonb_set(
${column},
'{payload}',
${JSON.stringify(payload)}::jsonb,
true
)`原生 SQL 插值绕过了列类型的 toDriver。在这套版本和驱动组合下,手动生成的 JSON 字符串经过参数绑定后又被编码了一次,PostgreSQL 最终收到的是一个合法的 JSON 字符串标量,而不是对象。
这里需要说清楚:::jsonb 本身不是错误,PostgreSQL 也完全能够把一段正确的 JSON 文本转换成 JSONB。问题出在 Drizzle 0.45.x、Bun.SQL、自定义列适配和原生 SQL 参数绑定同时存在时,值经过了两条不同的编码路径。不能因为普通列写入已经修过,就假设所有 sql 模板里的 JSON 值也会自动走同一个适配器。
改由 PostgreSQL 构造 JSONB
修复时我没有继续调整字符串转义,而是取消整对象序列化,让 PostgreSQL 明确构造对象和数组:
const values = sql`jsonb_build_array(${sql.join(
payload.values.map(value => sql`${value}::text`),
sql.raw(', '),
)})`
return sql`jsonb_set(
${column},
'{payload}',
jsonb_strip_nulls(jsonb_build_object(
'state', ${payload.state}::text,
'values', ${values},
'createdAt', ${payload.createdAt}::bigint
)),
true
)`jsonb_build_object 和 jsonb_build_array 在数据库内部确定结构,字段值仍然通过参数绑定传入。这样既保留参数化查询,也不再让一个完整 JSON 对象跨越多层编码边界。
我后来给 JSONB 写入加的防线
这类问题靠 TypeScript 检查不出来。应用层的对象类型再准确,也无法证明数据库中的 JSONB 仍然是 object。
我最后保留了三层检查:
- 自定义 JSONB 类型的回归测试,确认 Bun.SQL 收到的是对象或数组,不是提前序列化的字符串;
- 原生 SQL 写入测试,确认嵌套字段保持 object 或 array;
- 涉及嵌套 JSONB 的新写入,上线前用只读 SQL 检查目标路径的
jsonb_typeof(...)。
原生 SQL 的规则也很简单:不要在这套技术栈里把对象或数组先 JSON.stringify(...),再绑定到 ::jsonb。需要局部更新 JSONB 时,优先使用 jsonb_build_object、jsonb_build_array、jsonb_set 等 PostgreSQL 函数组装结构。
这次故障最容易误导人的地方,是每一层看到的值都“差不多正确”:数据库文本里有 READY,接口也能返回一段 JSON,应用层只表现为条件不成立。真正有用的检查不是继续打印属性值,而是追问一句:它在数据库里到底是什么类型。