“我让智能体跑迁移,现在的工作就是这么干的。”作者在回顾自己开发 diffium-db 的过程时这样描述当下的开发方式:你描述想要什么,某个东西写出 SQL,你读一遍,批准,几秒钟后数据库结构就变了。问题出在之后的十分钟。
![]()
git diff 能给你看迁移文件,却看不出这次迁移到底做了什么;更看不到智能体顺手改掉的那一行数据——因为在当时看来,改那一行挺合理的。
市面上有不错的 schema diff 工具,但用法大同小异:事后拿两个数据库跑一遍,交给你一份差异。工作正在进行时,身边没有任何东西是开着的。于是他做了 diffium-db——一个终端界面工具,设计目标就是一直开着陪你干活。
它怎么工作
把它指向一个 Postgres,取一个基线,然后让监视器开着。从那一刻起,所有变化列在左边,变化本身显示在右边,每秒重新读一次数据库。左栏是基线以来的全部变化,右栏是选中对象的前后对比。
它监视表(列、默认值、identity、约束、索引、触发器)、视图、物化视图、枚举和函数。它也监视行——作者说,这后来成了更有意思的那一半。
命令一共四个:watch 打开界面,snapshot 取基线,baselines 列出已有的基线,diff 打印变化后退出。最后一个支持 --exit-code,只要有变化就返回 1,足以让 CI 任务失败,或者在智能体做下一步之前掐断它的循环。
环境要求是 Bun 1.3 或更新版本,外加一个要监视的 Postgres。除此之外只有一个依赖 @opentui/core,安装很快。仓库自带演示:01-baseline.sql 建一个含 users 和 projects 的小 schema,02-agent-change.sql 模拟你对智能体说“加计费方案并清理一下”之后它干的事。两个终端,一个开着监视器,另一个跑第二个脚本,屏幕上会落下五处变化:一个新枚举、demo.users 上两个新列和一个新索引、demo.projects 少了一个索引、一行插入、一行删除。
别指向生产库
演示归演示。真实工作里不要把它指向生产环境。目的是让智能体放手去跑,事后再读它做了什么,所以这件事应该发生在你能随手扔掉的地方。
作者的做法是给智能体一个分支。在 Neon 上是两条命令加一个项目 id,因为他不止一个项目,命令行工具拒绝猜。把 DATABASE_URL 指向第二条命令打印出来的连接串,在那里取基线,让智能体在分支上干活。分支是父库的写时复制,起来时带着和你关心的数据库相同的 schema 和相同的行。他写这篇文章时建的那个分支花了 1.2 秒。空闲分支还会自动挂起自己的计算资源,忘了删的也不会一直占着跑。
然后读差异。迁移是你要的,就对真实数据库执行;智能体做了你没预料的事,你是在副本上发现的,副本删掉即可。任何 Postgres 都能用,工具里没有任何 Neon 专属的东西——分支只是让这套流程便宜到可以每次都做,而不是想起来才做。
行差异能证明什么
结构是容易的那一半,行才是让人不舒服的地方。
要告诉你某一行变了,就得记住它原来是什么。存下每一行对任何真实场景都不现实,所以 diffium-db 存的是指纹:主键,加上整条记录的 md5(t::text),再加一小段预览。在数据库里算起来便宜,存起来也便宜,而且能精确区分插入、更新和删除:键是新的就是插入,键没了就是删除,键相同而哈希变了,就是有人改了这一行。
这套办法在表新增一列时会失效。那一刻整张表的哈希全变,天真的工具会告诉你每一行都被编辑过。没人编辑它们,是形状在它们脚下动了。
diffium-db 不这么干。左栏写的是 ~3?,差异顶部还有一行说明。它检测到了列变化,标记了这张表,并告诉你它自己的答案里哪一半可信。计数写成 ~3? 而不是 ~3,差异第一行解释了原因:来了两列,所以表里每一行都读起来像被编辑过,更新数就是这个答案里工具无法背书的那部分。
插入和删除仍然精确,因为它们来自主键,而主键不在乎形状。只有更新数是猜测,把一个猜测和能证明的数字并排平铺,会让整个屏幕的价值打折扣。没有主键的表、以及超过 --row-limit(默认 5000)的表,只给行数,不做任何断言。同一个原则:宁可少说。
结构就是文本
结构差异建立在一个决定上:每个对象都渲染成一段规范文本,行序确定。一张表先变成它的列,再是约束,再是索引,再是触发器,永远这个顺序。结构差异于是只是这些文本块的行差异。
这一个选择带来很多好处:加一列就是加一行,而不是一张表含糊地“变了”;并排视图下 +2 -0 的计数、横向滚动看一长串默认值,全都免费得到。
代价也有:diffium-db 告诉你数据库现在长什么样,而不是能把你带到那里的 DDL 语句。对一个监视器来说,这是正确的取舍。想要生成迁移,你需要另一个程序。
基线存在哪
基线是一个存储的值,不是活连接,所以你可以今天取、明天比,可以存多个命名基线,也可以把它和产生它的迁移一起提交。默认放在 .diffium-db/snapshots/ 下的 JSON 里;传 --store neon --store-url 则存进 Postgres 的一个 diffium_db schema。当 CI 和你的笔记本需要对“之前”达成一致时用后者。监视器从不看自己的 schema,所以存储 URL 和被监视 URL 可以是同一个数据库。
配合上面的分支,基线可以就放在分支上,和它描述的东西隔一个 schema。整个实验就是那个分支,删掉它,记录也一起没了。
v1 不做的事
- 没有 ORM 感知。v1 告诉你多了一列,但说不出是哪个迁移文件、哪个模型定义放进去的。这个映射是 ORM 的事,也是他下一个想做的,src/orms/ 就是它要去的地方,有文档,故意留空。
- 只支持 Postgres。读 catalog 的代码隔离在 src/pg/,边界之上不知道 catalog 是什么,所以第二种方言有明确的去处。它还不存在。
- 没有序列。每个 bigserial 都会造一个,每次插入都会动,在有人提需求之前纯属噪音。
- 轮询,不是复制。它每秒重读一次。在大库上逻辑复制会便宜得多,如果轮询哪天开始疼了,那是显然该做的事。
有一件事不在清单上,也永远不会在:diffium-db 不向它监视的数据库写入。它唯一写的是自己的存储,而且只在你指定了存储时。
收尾
代码大约 2,200 行,测试 900 行。其中 64 个测试完全不需要数据库,包括终端相关的测试,因为 OpenTUI 会渲染到内存里,让你对画出来的屏幕做断言。所以布局改动会让测试失败,而不是悄悄地在某张忘了重截的截图里看起来不对。
作者说他反复回到行差异这件事上。给每张表打一个数字、让人自行假设它有意义,本来更容易。那个会说“这个我证明不了”的版本,看起来丑一点。他接受。一个坐在智能体旁边、汇报它干了什么的工具,必须比智能体更谨慎。
项目在 GitHub 上以 MIT 协议开源。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.