Claude Fable 帮助稳定 sqlite-utils 4.0rc2
Simon Willison··作者 Simon Willison
关键信息
最严重的问题是 delete_where() 没有正确提交事务,并让 SQLite 连接停留在事务状态,导致后续 atomic 操作也无法正常提交,从而可能在重新打开数据库后丢失数据。Willison 还表示,新的 4.0 版本明确记录了一个事务模型:所有写入方法都会立即提交,包括通过 db.execute() 执行的写语句。
资讯摘要
Simon Willison 说,他在推进 sqlite-utils 4.0 稳定版之前,先让 Claude Fable 对 4.0rc2 做了一次最终审查。此前他已经写过 4.0rc1 的发布说明,但希望在进入稳定版前再确认没有会迫使之后发布破坏性修复的遗留问题。于是他先在 iPhone 上的 Claude Code for web 中输入提示,请求对稳定版发布前的关键内容做最后检查。Claude Fable 给出的初始报告找出了 5 个发布阻塞问题。最严重的问题是 delete_where() 没有正确提交 DELETE 操作,并把连接留在 in_transaction 状态,导致后续的 atomic 操作都走保存点分支而不会真正提交。
Willison 复现了这个问题,发现它会造成数据丢失:删除、后续插入以及其他表的写入在重新打开数据库后都消失了。尽管如此,他认为这仍然可以通过 4.0.1 这样的补丁版本修复,而不必上升到 5.0 级别的设计变更。整个过程持续了 37 次提示、34 次提交,以及跨 30 个文件的 +1,321/-190 代码变更。Willison 还提到,编码代理的一个优点是此类任务常常需要 10 到 15 分钟的自动处理时间,让他可以同时去做别的事情,比如去参加 Half Moon Bay 的 7 月 4 日游行。最后的审查转移到他的笔记本电脑上,并通过 GitHub 的 PR 界面完成;新版还补充了新的事务模型文档,强调所有写入方法都会在返回前完成提交。

资讯正文
sqlite-utils 4.0rc2,主要由 Claude Fable 编写(花费大约 $149.25)
2026 年 7 月 5 日
几周前我写过 sqlite-utils 4.0rc1 的发布。由于我们的 Max 订阅里只剩下几天还能使用 Claude Fable,我决定看看它能否帮助我推进到一个让我真正放心的 4.0 稳定版,因为我一直尽量遵守 SemVer,并希望我的不兼容重大版本尽可能少见。
我先在 iPhone 上的 Claude Code for web 里用了这个提示:
在发布稳定的 4.0 版本之前做最终审查——非常重要,要找出任何最后时刻、如果我们之后修复就会变成 breaking change 的问题
在发布稳定的 4.0 版本之前做最终审查——非常重要,要找出任何最后时刻、如果我们之后修复就会变成 breaking change 的问题
下面是它为我生成的初始报告。其中有一些我自己还没有遇到过的严重问题——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` 为 `True`,`db["t"].insert({ "id": 50 })`,`db["u"].insert({ "a": 1 })`,`db.close()` # 重新打开后:行是 `[0, 1, 2]` —— 删除操作、行 50,以及表 `u` 全都没了。
这真是个非常糟糕的 bug!很高兴我没有把它发出去,不过至少它会是一个我可以在 4.0.1 的小版本里修复的问题,而不是一个会迫使我直接升到 5.0 的设计缺陷。
在 37 个提示、34 次提交,以及跨 30 个不同文件共计 +1,321 / -190 行代码变更的过程中,我们逐一处理了整套反馈,并且一路上还做了几项其他设计改进。
关于编码代理,有件奇怪的事:像这样的更难任务,实际上会带来更多同时做别的事的机会,因为代理有时需要 10 到 15 分钟来消化一个新任务。我去欣赏了 Half Moon Bay 的 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"}) # 新行已经保存——不需要 commit()。通过 db.execute() 执行的原始 SQL 也同样如此——写入语句在运行完毕后就会被提交。你永远不需要调用 commit(),也不需要关闭数据库来持久化更改。只有两种情况下你需要考虑事务:你想把多次写操作组合在一起,让它们要么全部成功、要么全部失败——请使用 db.atomic()。或者你正在用 db.begin() 自己管理事务,在这种情况下,在你提交之前不会有任何内容被提交——库绝不会提交你打开的事务。
这个库中所有会向数据库写入内容的方法——insert()、upsert()、update()、delete()、delete_where()、transform()、create_table()、create_index()、enable_fts() 以及其他方法——都会在自己的事务中运行,并在返回前提交。你的更改会在方法调用结束后立即保存到磁盘:
通过 db.execute() 执行的原始 SQL 也同样如此——写入语句在运行完毕后就会被提交。
你永远不需要调用 commit(),也不需要关闭数据库来持久化更改。只有两种情况下你需要考虑事务:
你想把多次写操作组合在一起,让它们要么全部成功、要么全部失败——请使用 db.atomic()。
你想把多次写操作组合在一起,让它们要么全部成功、要么全部失败——请使用 db.atomic()。
或者你正在用 db.begin() 自己管理事务,在这种情况下,在你提交之前不会有任何内容被提交——库绝不会提交你打开的事务。
或者你正在用 db.begin() 自己管理事务,在这种情况下,在你提交之前不会有任何内容被提交——库绝不会提交你打开的事务。
在审阅 Fable 的文档时——我发现先审阅文档修改是建立对变更初步理解的绝佳方式——我注意到了这条细节:
db.atomic() 和自动的逐方法事务是为 Python 默认事务处理模式下的连接设计的。使用 Python 3.12+ 的 sqlite3.connect(..., autocommit=True) 或 autocommit=False 选项创建的连接不受支持,因为在这些连接上 commit() 和 rollback() 的行为不同。
db.atomic() 以及自动的按方法事务机制,是为 Python 默认事务处理模式下的连接而设计的。使用 Python 3.12+ 的 sqlite3.connect(..., autocommit=True) 或 autocommit=False 选项创建的连接不受支持,因为在这些连接上,commit() 和 rollback() 的行为不同。
我承认自己之前没有想到 sqlite-utils 会如何响应 Python 3.12 中新增的较新的 autocommit 设置。结果发现,“在这些连接上的行为不同”几乎等同于整个测试套件都失败了,所以我和这个模型一起工作,以确保这种差异不会破坏库的工作方式。
我以前认为让一个模型审查另一个模型的工作有点荒谬——感觉莫名其妙地像迷信。但问题是它确实有效——我已经开始习惯性地让 Anthropic 的最佳模型审查 OpenAI 的工作,反之亦然,因为我已经多次发现这样会产生有价值的有趣结果。
我向 Codex Desktop 和 GPT-5.5 xhigh 提示了以下内容:
Review changes since the last RC. Also confirm that the changelog is up-to-date.
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 的说明相矛盾,后者说它在不迭代的情况下就会生效。
发现
[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(...)) 或者一次未迭代的调用都会让事务保持打开状态,这与变更日志和文档所承诺的内容相矛盾。
这两个发现都得到了确认。db.query() 先调用了 self.execute(),而这一步会自动提交写入,然后才检查 cursor.description——所以 db.query("update ...") 会在抛出 ValueError 之前就提交更新。与此同时,INSERT ... RETURNING 的提交逻辑位于返回的生成器末尾,因此除非你把迭代器完全消费完,否则它根本不会执行——next(db.query(...)) 或者一次未迭代的调用都会让事务保持打开状态,这与变更日志和文档所承诺的内容相矛盾。
这是带修复的 PR,以及完整的 Claude Code 对话记录。审阅这段代码帮助我更好地建立了对 SQLite 事务语义边缘情况的心理模型!
我升级到了 Claude Max 每月 200 美元的套餐(我之前是每月 100 美元),以便在 7 月 7 日 Fablepocalypse 之前的剩余时间里增加我的 Fable 配额;到了那天,即使是 Claude Max 订阅者也必须按模型的完整 API 成本付费。
我很好奇,如果我当时是直接按这些成本付费,这会花我多少钱。起初我以为我拿不到这些数字,因为我是通过 Claude Code for web 远程完成这项工作的,后来我意识到我可以在那个现有会话中运行 AgentsView 来获取成本估算!
运行“uvx agentsview --help”,然后使用该工具来计算这个会话的成本
运行“uvx agentsview --help”,然后使用该工具来计算这个会话的成本
Claude 想出了如何使用 session list --include-children 命令,并得出了以下结果:
我真的很庆幸自己订了这个套餐!我真该采纳自己的建议,更大力度地使用更便宜模型的 subagents。
这是 claude.ai/settings/usage 现在显示给我的内容:
我目前还在推进另外几个由 Fable 驱动的重要项目,目标是在价格上涨前刚好把这个 Fable 进度条冲到 100%。
以下是该 RC 的完整发布说明。我让 Fable 在每项变更落地时都把它们添加到变更日志的“Unreleased”部分,并在过程中进行审阅。这样做有一个很妙的副作用:变更日志的提交历史会成为本次发布所包含各项变更的简明摘要。
过去我一直有手写发布说明的习惯,但说实话,这些说明比我自己写出来的还要好。发布说明是一个很适合外包给 agents 的写作任务,因为它们需要做到无聊、可预测且准确。
重大变更:通过 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 标志运行时,这些断言会被静默跳过。过去在这些情况下捕获 AssertionError 的代码,现在应改为捕获 ValueError。table.upsert() 和 table.upsert_all() 现在如果某条记录缺少任一主键列的值,或者该值为 None,就会抛出 PrimaryKeyRequired。以前这类记录——它们永远不可能匹配到现有行——会被悄悄作为全新行插入,或者在插入已经发生之后才触发令人困惑的 KeyError。db.enable_wal() 和 db.disable_wal() 现在如果在事务打开时被调用,会抛出 sqlite_utils.db.TransactionError。此前它们会在更改 journal mode 的过程中静默提交当前打开的事务,从而破坏 db.atomic() 以及用户自行管理事务时对回滚的保证。View 类不再有 enable_fts() 方法。它过去仅仅是为了抛出 NotImplementedError,因为视图不支持全文搜索——现在调用它会抛出 AttributeError,而且该方法也不再出现在 API 参考中。sqlite-utils enable-fts 命令在指向视图时会显示清晰的错误信息。插入和 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() 的行为不同,这此前导致库所做的每一次写入在连接关闭时都被悄无声息地丢弃。除此之外:修复了一个 bug,即 table.delete_where()、table.optimize() 和 table.rebuild_fts() 没有提交其更改,导致连接处于一个未结束的事务中。它们的工作——以及任何后续写入——在连接关闭时都可能被悄无声息地回滚。现在这三个方法都改用了 db.atomic(),与其他写入方法保持一致。sqlite-utils 的 drop-table 命令现在会拒绝删除视图,而 drop-view 会拒绝删除表。此前,如果名称匹配,每个命令都会悄悄删除错误类型的对象。现在两者都会以错误退出,并建议使用正确的命令。由新的 migrations 系统应用的迁移现在会在一个事务中运行,同时还会记录该迁移已被应用。如果某个迁移抛出异常,它的更改会被回滚,并且仍保持待应用状态,因此在修复错误后可以安全地重新应用。无法在事务中运行的迁移,例如执行 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 页面则详细说明了在主要版本之间迁移所需的变更。
破坏性变更:
通过 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 标志运行时,这些断言会被静默跳过。针对这些情况捕获 AssertionError 的代码,现在应改为捕获 ValueError。
table.upsert() 和 table.upsert_all() 现在会在记录缺少任何主键列的值,或者某个主键列的值为 None 时抛出 PrimaryKeyRequired。以前,这类记录——它们绝不可能匹配现有行——会被悄悄当作全新行插入,或者在插入已经发生后引发令人困惑的 KeyError。
db.enable_wal() 和 db.disable_wal() 现在如果在事务打开时被调用,会抛出 sqlite_utils.db.TransactionError。以前,它们会在更改 journal mode 的同时静默提交那个未完成的事务,从而破坏 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() 行为不同,这此前会导致库执行的每一次写入在连接关闭时都被静默丢弃。
其他改动:
修复了一个 bug:table.delete_where()、table.optimize() 和 table.rebuild_fts() 没有提交它们的更改,导致连接处于一个未结束的事务中。它们的工作,以及之后的任何写入,都可能在连接关闭时被静默回滚。现在这三个方法都使用 db.atomic(),与其他写入方法保持一致。
sqlite-utils 的 drop-table 命令现在会拒绝删除视图,而 drop-view 也会拒绝删除表。此前,如果名称匹配,这两个命令都会悄悄删错对象。现在它们都会以错误退出,并提示应使用的正确命令。
由新的 migrations 系统应用的迁移现在会在事务内运行,同时记录该迁移已被应用。如果某个迁移抛出异常,它的更改会被回滚,并保持待处理状态,因此在修复错误后可以安全地重新应用。不能在事务内运行的迁移,例如执行 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 页面则详细说明了在不同主版本之间迁移所需的更改。
来源与参考
收录于 2026-07-06