sqlite-utils 4.0rc2,主体由 Claude Fable 编写(花费约 149.25 美元)
原文由 Simon Willison 于 发布,订阅该博客
几周前我写过关于 sqlite-utils 4.0rc1 发布的文章。我们的 Max 订阅还能使用 Claude Fable 的时间只剩下几天,于是我决定看看它能否帮我打磨出一个让我真正放心的 4.0 正式版,毕竟我一直遵守 SemVer,也希望不兼容的大版本更新越少越好。
我在 iPhone 上的 Claude Code 网页版中,用这样一条提示词作为起点:
Final review before shipping a stable 4.0 release - very important to spot any last minute things that would be a breaking change if we fix them later
这是它为我生成的初始报告。其中有一些我自己还没遇到过的重大问题——Fable 将其中 5 个归为“发布阻断性问题”。下面是最严重的一个:
1.
delete_where()从不提交并会污染连接(导致数据丢失)
Table.delete_where()(sqlite_utils/db.py:2948)通过裸露的self.db.execute()执行 DELETE,没有用atomic()包裹——对比db.py:2944处的Table.delete(),后者做了正确的包裹。连接会处于in_transaction=True的状态,因此之后每一次 后续的atomic()调用都会走 savepoint 分支(db.py:430-440),同样永远不会提交。可完整复现如下:
db = sqlite_utils.Database("dw.db") db["t"].insert_all([{"id": i} for i in range(3)], pk="id") db["t"].delete_where("id = ?", [0]) # conn.in_transaction is now True db["t"].insert({"id": 50}) db["u"].insert({"a": 1}) db.close() # Reopen: rows are [0, 1, 2] — the delete, row 50, AND table u are all gone.
这是个非常严重的 bug!很庆幸没有带着它发布,好在即便发布了,也可以在 4.0.1 这样的补丁版本中修复,而不是会迫使我发布 5.0 的设计缺陷。
在 37 条提示词、34 次提交、跨 30 个文件的 +1,321 -190 行代码改动之后,我们逐一处理了全部反馈,期间还顺带做了其他几处设计上的改进。
编程智能体有一个奇怪的地方:越是像这样困难的任务,反而越能让你同时去做别的事,因为智能体有时需要花上 10 到 15 分钟来处理一项新任务。我就趁机出门去看了半月湾的 7 月 4 日游行,期间偶尔在手机上查看进度,给 Fable 下达下一步指令。
完整细节见 这个 PR 和这份共享的对话记录。最后的复审我切换到了笔记本电脑,通过 GitHub 的 PR 界面完成。
最重要的改动与事务处理有关,这也是上一个 RC 中的标志性新功能。新 RC 现在包含了关于新事务模型的完整文档,下面全文引用其引言部分:
该库中每一个写入数据库的方法——
insert()、upsert()、update()、delete()、delete_where()、transform()、create_table()、create_index()、enable_fts()等等——都会在各自的事务中运行,并在返回前提交。方法调用一结束,你的改动就会保存到磁盘:db = Database("data.db") db.table("news").insert({"headline": "Dog wins award"}) # The new row is already saved - no commit() required使用 db.execute() 执行的原生 SQL 也遵循同样的规则——写入语句一经执行就会被提交。
你永远不需要调用
commit(),也不需要关闭数据库来持久化改动。只有两种情况需要你考虑事务:
你想把多个写入操作打包在一起,要么全部成功,要么全部失败——请使用 db.atomic()。
你正在自行管理事务,使用了
db.begin(),此时在你提交之前不会有任何内容被提交——对于你手动开启的事务,库不会擅自提交。
在审阅 Fable 写的文档时——我发现先看文档的修改是快速搞清楚改了什么的绝佳方式——我注意到了这样一个细节:
db.atomic()和按方法自动执行的事务是为 Python 默认事务处理模式下的连接设计的。不支持使用 Python 3.12+ 中sqlite3.connect(..., autocommit=True)或autocommit=False选项创建的连接,因为commit()和rollback()在这些连接上的行为有所不同。
我得承认,我之前没考虑过 sqlite-utils 会如何应对 Python 3.12 中新增的 autocommit 设置。结果“在这些连接上行为不同”意味着几乎整个测试套件都会失败,于是我与模型合作,确保这种差异不会破坏库的正常运行。
以及由 GPT-5.5 完成的最终复审
我过去觉得让一个模型去复审另一个模型的工作有点荒谬——感觉带着一种奇怪的迷信色彩。问题在于它真的管用——我已经养成了习惯,让 Anthropic 最强的模型去复审 OpenAI 的成果,反之亦然,因为这种做法已经足够多次带来了有价值的发现。
我用以下提示词让 Codex Desktop 和 GPT-5.5 xhigh 进行复审:
Review changes since the last RC. Also confirm that the changelog is up-to-date.
仅这一句就找出了两个值得进一步研究的问题:
发现的问题
- [P1] sqlite_utils/db.py:663
db.query()现在只有在调用db.execute()之后才会拒绝非返回行的语句,而 sqlite_utils/db.py:705 会先自动提交这些写入。因此db.query("update ...")会抛出ValueError,但更新已经提交。对于一个文档中描述为“只能用于返回行的 SQL”的方法来说,这是一个令人意外的副作用。- [P1] sqlite_utils/db.py:672 通过
db.query()执行的INSERT ... RETURNING只有在返回的生成器被完全耗尽后才会提交。不进行迭代的db.query("insert ... returning ..."),或常见的next(db.query(...))用法,会让事务保持开启状态,写入在关闭时可能被回滚。这与 docs/changelog.rst:15 和 docs/python-api.rst:232 中所说的无需迭代即可生效相矛盾。
我把这份结果粘贴到一个新的 Fable 会话中,它通过一些实验确认了问题:
两个发现都得到了确认。
db.query()会先调用self.execute(),后者会自动提交写入,然后才检查cursor.description——因此db.query("update ...")会在抛出ValueError之前就提交更新。而INSERT ... RETURNING的提交逻辑位于返回生成器的末尾,所以除非耗尽迭代器否则永远不会触发——next(db.query(...))或未进行迭代的调用会让事务保持开启,与 changelog 和文档中的承诺相矛盾。
修复见这个 PR,以及完整的 Claude Code 对话记录。复审这些代码帮助我对 SQLite 事务语义的边界情况建立了更清晰的心智模型!
预估(未补贴)成本约为 149.25 美元
为了在7 月 7 日的 Fablepocalypse 到来之前——届时即便是 Claude Max 订阅用户也需为该模型支付全额 API 费用——尽可能增加 Fable 的使用额度,我将订阅从之前的 100 美元/月升级到了 Claude Max 200 美元/月。
我很好奇如果直接按量付费,这些工作会花掉我多少钱。起初我以为拿不到这些数字,因为我是通过 Claude Code 网页版远程运行的,后来才意识到我可以在现有会话中运行 AgentsView 来估算成本!
Run "uvx agentsview --help" and then use that tool to calculate the cost of this session
Claude 弄清楚了如何使用 session list --include-children 命令,并得出了如下结果:
| 对话记录 | 模型 | 费用 |
|---|---|---|
| 主会话 | claude-fable-5 | $141.02 |
| API 表面扫描智能体 | claude-fable-5 | $2.40 |
| 事务/atomic 复审智能体 | claude-fable-5 | $2.39 |
| rc1 之后提交复审智能体 | claude-fable-5 | $1.72 |
| 迁移复审智能体 | claude-fable-5 | $1.40 |
| 提示词计数智能体 | claude-opus-4-8 | $0.32 |
| 总计 | $149.25 |
很庆幸我用的是订阅制!我真该听从自己的建议,更多地借助更便宜模型的子智能体。
这是 claude.ai/settings/usage 目前显示给我的用量情况:

我手头还有几个其他由 Fable 驱动的重要项目正在进行,目标就是赶在涨价前把 Fable 的用量条用满 100%。
sqlite-utils 4.0rc2 完整发布说明
这是该 RC 的完整发布说明。我让 Fable 在每项改动落地时就将其添加到 changelog 的“Unreleased”部分,并一边进行一边复审。这样做有一个巧妙的副作用:changelog 的提交历史就成了本次发布中各项改动的简明总结。
过去我一直坚持手写发布说明,但说实话,这些说明比我自己写的更好。发布说明正是那种我愿意外包给智能体的写作——因为它们需要的是枯燥、可预期且准确。
不兼容变更:
- 使用
db.execute()执行的写入语句现在会自动提交,除非已有事务处于开启状态,此时则会加入该事务。此前它们会开启一个隐式事务并保持开启,直到有其他操作提交——在同一连接上读取时写入看似生效,但在连接关闭时会被静默回滚。依赖回滚未提交的db.execute()写入的代码,应改用新的db.begin()方法先显式开启事务。完整的事务模型见 Transactions and saving your changes。db.query()现在在调用时就会立即执行 SQL,而不是等到首次迭代返回的生成器时才执行。行数据仍在迭代过程中惰性获取。SQL 错误现在会在调用处抛出,诸如INSERT ... RETURNING这类语句会立即执行并提交,无需迭代其结果,而传入不返回任何行的语句——此前会被静默忽略——现在会抛出ValueError并建议改用db.execute()。以这种方式被拒绝的语句会在抛出错误前回滚,因此不会对数据库产生任何影响。- Python API 的校验错误现在抛出
ValueError,而非AssertionError。此前,无效参数——例如不带任何列的create_table()、对不存在的表调用transform(),或同时传入ignore=True和replace=True——是通过裸assert语句来拒绝的,而在 Python 使用-O标志运行时这些 assert 会被静默跳过。原先捕获AssertionError的代码应改为捕获ValueError。table.upsert()和table.upsert_all()现在会在某条记录缺少任一主键列的值,或其中某一主键值为None时抛出PrimaryKeyRequired。此前,这类永远无法匹配现有行的记录会被悄悄作为全新行插入,或在插入完成后触发令人困惑的KeyError。- 如果在事务开启期间调用
db.enable_wal()和db.disable_wal(),现在会抛出sqlite_utils.db.TransactionError。此前它们会以更改日志模式的副作用静默提交已开启的事务,从而破坏db.atomic()和用户自行管理事务的回滚保证。View类不再拥有enable_fts()方法。该方法过去仅用于抛出NotImplementedError,因为视图不支持全文搜索——现在调用它会改为抛出AttributeError,且该方法已不再出现在 API 参考中。当sqlite-utils enable-fts命令指向视图时会显示清晰的错误提示。- 已从
insert和upsert命令中移除无实际作用的-d/--detect-types标志。自 4.0a1 起,对 CSV/TSV 数据的类型检测已是默认行为,因此该标志实际上不起任何作用——使用它的调用只需去掉该标志即可。--no-detect-types仍可用于禁用检测。- 如果向
Database()传入了使用 Python 3.12+ 中sqlite3.connect(..., autocommit=True)或autocommit=False选项创建的连接,现在会抛出sqlite_utils.db.TransactionError。commit()和rollback()在这些连接上的行为不同,此前会导致库执行的所有写入在连接关闭时被静默丢弃。其他变更:
- 修复了
table.delete_where()、table.optimize()和table.rebuild_fts()未提交改动、导致连接滞留在开启事务中的 bug。它们的改动——以及之后的任何写入——都可能在连接关闭时被静默回滚。三者现在均使用db.atomic(),与其他写入方法的行为保持一致。sqlite-utils drop-table命令现在会拒绝删除视图,drop-view则会拒绝删除表。此前如果名称匹配,它们会静默删除错误类型的对象。现在两者都会报错退出,并提示应使用的正确命令。- 由新的 迁移系统应用的迁移现在会在事务中运行,并与“迁移已应用”的记录一起提交。如果某个迁移抛出异常,其改动会被回滚且该迁移保持待处理状态,因此在修复错误后可以安全地重新应用。无法在事务中运行的迁移,例如执行
VACUUM的迁移,可使用@migrations(transactional=False)选择退出——详见 Migrations and transactions。table.upsert()和table.upsert_all()现在会自动检测已存在表的主键或复合主键,因此在向已有主键的表执行 upsert 时不再需要传入pk=参数。- 现在可以使用
db.table(table_name).insert({})向已存在的表中插入一行完全由默认值组成的记录,底层使用INSERT INTO ... DEFAULT VALUES。(#759)- 对
sqlite-utils migrate命令的改进:与任何已知迁移都不匹配的--stop-before值现在会报错,而不是被静默忽略;--stop-before现在也能与仍使用旧版sqlite_migrate.Migrations类的迁移文件正确配合;--list现在是只读操作,不再创建数据库文件或迁移记录表。migrations.applied()现在会按应用顺序返回迁移。- 新增
db.begin()、db.commit()和db.rollback()方法,用于手动控制事务,作为db.atomic()上下文管理器的替代方案。- 新增文档:Transactions and saving your changes 介绍了事务的工作原理以及改动的提交时机,新增的 Upgrading 页面则详细说明了在不同大版本之间升级所需的改动。
随机一篇博客
评论
登录后参与讨论