Drizzle ORM 与 Bun.SQL 的 JSONB 双重编码问题

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
Drizzle ORMBun.SQLPostgreSQLJSONB故障排查

我在给一个 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_objectjsonb_build_array 在数据库内部确定结构,字段值仍然通过参数绑定传入。这样既保留参数化查询,也不再让一个完整 JSON 对象跨越多层编码边界。

我后来给 JSONB 写入加的防线

这类问题靠 TypeScript 检查不出来。应用层的对象类型再准确,也无法证明数据库中的 JSONB 仍然是 object。

我最后保留了三层检查:

  • 自定义 JSONB 类型的回归测试,确认 Bun.SQL 收到的是对象或数组,不是提前序列化的字符串;
  • 原生 SQL 写入测试,确认嵌套字段保持 object 或 array;
  • 涉及嵌套 JSONB 的新写入,上线前用只读 SQL 检查目标路径的 jsonb_typeof(...)

原生 SQL 的规则也很简单:不要在这套技术栈里把对象或数组先 JSON.stringify(...),再绑定到 ::jsonb。需要局部更新 JSONB 时,优先使用 jsonb_build_objectjsonb_build_arrayjsonb_set 等 PostgreSQL 函数组装结构。

这次故障最容易误导人的地方,是每一层看到的值都“差不多正确”:数据库文本里有 READY,接口也能返回一段 JSON,应用层只表现为条件不成立。真正有用的检查不是继续打印属性值,而是追问一句:它在数据库里到底是什么类型。