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
這是它幫我產生的那份初始報告。裡面指出了一些我自己還沒遇過的重大問題——其中有 5 個被 Fable 列為「阻擋發佈的問題」。最嚴重的是這一個:
1.
delete_where()從未提交並污染連線(導致資料遺失)
Table.delete_where()(sqlite_utils/db.py:2948) 直接用裸的self.db.execute()執行 DELETE,沒有用atomic()包起來——對比一下Table.delete()在db.py:2944就正確地包了起來。連線因此停留在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 行程式碼的修改過程中,我們逐一處理了整份回饋,並順手做了好幾項其他的設計改進。
寫程式的 agent 有個奇怪的地方是,像這樣比較困難的任務反而讓你有更多時間同時做別的事,因為 agent 有時需要花 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"}) # 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 在比較新的 autocommit 設定(Python 3.12 新增)下會有什麼反應。結果所謂的「在那些連線上會有不同的行為」,實際上就是幾乎整個測試套件都會失敗,所以我跟模型一起合作,確保了這個差異不會破壞函式庫的運作。
再由 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要等到回傳的 generator 被完全消耗後才會提交。未經迭代的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的提交邏輯放在回傳的 generator 結尾,所以除非你把迭代器跑完,否則根本不會觸發——next(db.query(...))或完全不迭代的呼叫都會讓交易保持開啟,這與更新日誌和文件中的承諾互相矛盾。
修正的內容在這個 PR,以及完整的 Claude Code 對話紀錄中。審閱這段程式碼幫助我對 SQLite 交易語意的各種邊界情況建立了更清晰的心智模型!
估計(未補貼)花費 149.25 美元
我把方案升級到每月 200 美元的 Claude Max 方案(原本是每月 100 美元),以便在 7 月 7 日 Fablepocalypse 到來前的剩餘時間內增加 Fable 的使用額度,到那時即使是 Claude Max 訂閱者也得為該模型支付完整的 API 費用。
我很好奇如果直接按 API 計費,這會花我多少錢。一開始我以為拿不到這些數字,因為我是透過網頁版 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 介面掃描 agent | claude-fable-5 | $2.40 |
| 交易 / atomic 審查 agent | claude-fable-5 | $2.39 |
| rc1 後提交審查 agent | claude-fable-5 | $1.72 |
| 遷移審查 agent | claude-fable-5 | $1.40 |
| 提示計數 agent | claude-opus-4-8 | $0.32 |
| 總計 | $149.25 |
還好我有訂閱那個方案!我其實應該要聽從自己的建議,更積極地使用搭配較便宜模型的子代理人(subagents)。
這是 claude.ai/settings/usage 目前顯示給我的資訊:

我手上同時還有好幾個其他由 Fable 驅動的大型專案,目標是在價格調漲前剛好把那個 Fable 的使用額度用到 100%。
sqlite-utils 4.0rc2 完整版本更新說明
這是這個 RC 的完整版本更新說明。我讓 Fable 在每次變更完成後,就把內容加到更新日誌的「Unreleased」區塊,並一邊審閱。這有個不錯的副作用,就是更新日誌的提交紀錄本身就成了這次版本所有變更的精簡摘要。
過去我一直堅持手寫版本更新說明,但老實說這些比我自己寫的還要好。版本更新說明正是那種我很樂意外包給 agent 來寫的內容,因為它們需要的就是無聊、可預期且準確。
破壞性變更:
- 使用
db.execute()執行的寫入語句現在會自動提交,除非已經有交易正在進行,此時它們會加入該交易。以前它們會開啟一個隱含交易,直到有東西提交它才會結束——寫入在同一個連線上讀取時看似有作用,但關閉連線時卻會被默默回滾。依賴回滾未提交的db.execute()寫入的程式碼,現在應該先使用新的db.begin()方法開啟一個明確的交易。完整的交易模型記載於 Transactions and saving your changes。db.query()現在會在被呼叫時立刻執行 SQL,而不是等到回傳的 generator 第一次被迭代時才執行。資料列仍會在迭代過程中惰性取得。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 模式時默默提交已開啟的交易,破壞db.atomic()和使用者自行管理的交易的回滾保證。View類別不再有enable_fts()方法。它原本只是用來拋出NotImplementedError,因為檢視不支援全文檢索——現在呼叫它會改為拋出AttributeError,且該方法也不再出現在 API 參考文件中。當sqlite-utils enable-fts指令指向檢視時,現在會顯示一個清楚的錯誤訊息。insert和upsert指令中無作用的-d/--detect-types旗標已被移除。自 4.0a1 起,CSV/TSV 資料的類型偵測已是預設行為,因此該旗標實際上沒有任何作用——有使用它的呼叫只要直接移除即可。--no-detect-types仍可使用來停用偵測。- 若傳入使用 Python 3.12+ 的
sqlite3.connect(..., autocommit=True)或autocommit=False選項所建立的連線,Database()現在會拋出sqlite_utils.db.TransactionError。commit()和rollback()在那些連線上的行為不同,過去會導致函式庫執行的所有寫入在連線關閉時被默默丟棄。其他變更:
- 修正了
table.delete_where()、table.optimize()和table.rebuild_fts()未提交變更的錯誤,這會讓連線停留在開啟的交易中。它們的操作——以及任何後續的寫入——接著就可能在連線關閉時被默默回滾。現在這三個方法都改用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()context manager 的替代方案。- 新增文件:Transactions and saving your changes 說明了交易如何運作以及變更何時會被提交,而新的 Upgrading 頁面則詳述了在不同主要版本之間升級所需的變更。
隨機一篇部落格
留言
登入後參與討論