sqlite-utils 4.0rc2, mostly written by Claude Fable (for about $149.25)

Simon Willison

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(),也不需要關閉資料庫來保存變更。只有兩種情況你需要考慮交易:

  1. 你想把多個寫入操作綁在一起,讓它們要嘛全部成功、要嘛全部失敗——請使用 db.atomic()

  2. 你正在用 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:15docs/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 介面掃描 agentclaude-fable-5$2.40
交易 / atomic 審查 agentclaude-fable-5$2.39
rc1 後提交審查 agentclaude-fable-5$1.72
遷移審查 agentclaude-fable-5$1.40
提示計數 agentclaude-opus-4-8$0.32
總計$149.25

還好我有訂閱那個方案!我其實應該要聽從自己的建議,更積極地使用搭配較便宜模型的子代理人(subagents)。

這是 claude.ai/settings/usage 目前顯示給我的資訊:

Claude 方案使用額度面板的截圖:「Plan usage limits Max (20x)」;「Current session」顯示「Resets in 3 hr 52 min」,進度條為「7% used」;「Weekly limits」標題旁有「Learn more about usage limits」連結;「All models」顯示「Resets Wed 12:00 PM」,進度條為「32% used」;「Fable」顯示「Resets Wed 12:00 PM」,進度條為「63% used」。

我手上同時還有好幾個其他由 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=Truereplace=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 指令指向檢視時,現在會顯示一個清楚的錯誤訊息。
  • insertupsert 指令中無作用的 -d/--detect-types 旗標已被移除。自 4.0a1 起,CSV/TSV 資料的類型偵測已是預設行為,因此該旗標實際上沒有任何作用——有使用它的呼叫只要直接移除即可。--no-detect-types 仍可使用來停用偵測。
  • 若傳入使用 Python 3.12+ 的 sqlite3.connect(..., autocommit=True)autocommit=False 選項所建立的連線,Database() 現在會拋出 sqlite_utils.db.TransactionErrorcommit()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 頁面則詳述了在不同主要版本之間升級所需的變更。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言