為什麼資料庫 GUI 永遠不該直接寫入
每個資料庫 GUI 都有那麼一刻:一次點擊變成了一筆對正式環境的 寫入。我們決定,那一刻不該存在。在 DynoTable 裡,你所做的任何事 — 不論是編輯一筆項目、大量刪除,還是某個 的變更 — 都不會直接碰到 DynamoDB。每一筆寫入 都會以可審閱的、逐屬性的 diff 落入一個, 並且只有在人類提交它時才會送出。
我們一路建構的心智模型就是 git:一個供審閱的暫存區,以及一次
行為就像 git push --force-with-lease 的提交 — 唯有當遠端仍維持
你上次所見的樣子時,它才會成功。這篇文章談的是底下的工程:那個
重塑了整套設計的跨分頁 bug、那次主要產出是被刪掉的程式碼的重構,
以及為什麼 AI agent 的到來,把一個 UX 上的貼心設計,變成了承重的
安全牆。
寫入即 diff
編輯會累積在一個由本機 SQLite 支撐的暫存中,並在側邊面板裡呈現為 diff 卡片 — 舊值、新值、逐屬性列出。資料列會在格線中染上色調, 使暫存狀態從資料本身就看得出來,而不只是在面板裡。提交會把這組 暫存的變更以 DynamoDB 的形式送出,並依服務的限制 切分成區塊(每筆交易 100 個項目,且每項操作與每個請求都有位元組 預算),循序執行,每個區塊有 30 秒的逾時。

暫存說明文件涵蓋了日常工作流程。它們沒有涵蓋的是 這個暫存是以什麼為鍵的 — 而這件事最後成了整個系統中影響最深遠的 決定。
以表格為鍵,而非以分頁為鍵
第一版把每個暫存的範圍限定在建立它的那個分頁。那個設計催生了一個 真實的 bug:AI 的暫存工具是從作用中的分頁推導出它的目標,而它的 防護會跳過非表格檢視的分頁。於是,如果你在一個 SQL Workbench 分頁 處於焦點時要求助理修正某一列,這次編輯就會被暫存進 Workbench 的 暫存中 — 而那個「顯示暫存變更」的晶片會開錯分頁。
更糟的是,提交鎖也是以分頁為單位的。兩個檢視同一張表格的分頁會 各自持有兩個獨立的鎖 — 這表示兩者可以同時把同一批暫存的資料列提交 到真正的 DynamoDB。一場針對正式環境的雙重提交競態,就這樣內建進了 資料模型裡。
修法是把一切改以表格識別為鍵 — {profile, region, tableName} —
而不再以分頁為鍵:
- 每張表格一個暫存,從它的每一個檢視都看得到,並能撐過分頁的關閉 與重開。AI 甚至可以在完全沒有開啟任何表格分頁的情況下進行暫存。
- 每張表格一把提交鎖。雙重提交競態並不是被「處理掉」— 而是變得 無法表述。
- 開錯分頁這類 bug 從結構上就消失了:鍵裡根本沒有分頁。
而且重新設鍵刪掉的程式碼比它加上的還多。那個在啟動時把已關閉分頁的 暫存做垃圾回收的清掃程序呢?一位審查者指出,在新模型下它會摧毀 功能 — 暫存不再隨分頁一起消亡 — 於是它被直接移除了。關閉分頁即捨棄, 以及它的確認對話框:移除。唯一剩下的真正孤兒,是被刪除的連線設定檔, 而這會以明確的方式串聯清除。
這次遷移本身是這個儲存庫裡第一個會變更資料列的遷移:一次手寫的
SQLite 表格重建,用一個 ROW_NUMBER() OVER (PARTITION BY …) 視窗
(最新的編輯勝出)把舊有的逐分頁資料列去重,並以 char(0) 分隔符
回填新的鍵,與執行期的鍵建構器逐位元組相同。會變更資料列的遷移,
在那天有了自己的「先植入種子再遷移」測試框架。
為 DynamoDB 打造的 --force-with-lease
如果你審閱的東西不是真正被寫入的東西,那麼審閱介面就毫無價值。在 暫存與提交之間,可能有其他人已經改動了那一列。所以每一項提交操作 都帶有,把這次寫入釘在你所審閱的 那個確切快照上 — ,逐屬性進行:
- 一次建立會斷言該項目尚不存在。
- 一次更新會斷言你要變更的每一個屬性,仍持有你在暫存時所見的值。
- 就連一次移除也會斷言該屬性仍等於它的舊值 — 如果某位隊友在你
對
note的移除還擱在暫存時,把note從"old"改成了"new", 那麼這次提交就絕不能默默刪掉他們的編輯。
當某個條件失敗時,DynamoDB 會取消整筆交易,並告訴我們是哪一個項目 發生了偏移。那一列會得到一個衝突橫幅 — 針對即時值重新變基(rebase), 或是中止 — 而不是任一方向上的默默覆寫。
而只要有任何一個區塊失敗,提交器就會停止。已提交的區塊維持 已提交,失敗的區塊會以原子方式回復,之後不再嘗試任何動作。我們 刻意否決了盡力而為式的續行:一個在遇到衝突後仍繼續寫入的審閱工具, 正在套用一組沒有人審閱過的變更集。
那次刪掉了一整個子系統的重構
提交是長時間執行的:應用程式會把每一次提交登錄到一個重播登錄檔中,
好讓 renderer 重新載入 — 或第二個視窗 — 能夠重新接上並看著它完成。
隨著時間過去,三條不同的程式碼路徑各自為每次提交接上了兩個
觀察者,而一整套仲裁機制就圍繞著一個問題長了出來:哪一個觀察者才
被允許釋放提交鎖?它有自己嚇人的名字(ownsLockOnBeforeRegistration)、
一段解釋 IPC 排序競態的註解區塊,以及一個 230 行的 toast 追蹤器。
修法是單一擁有者:一個提交工作階段(Commit Session),它每次 提交只接上恰好一次、獨佔地持有那把鎖、把進度投影到 store 裡,並 保證它的啟動呼叫在每一條離開路徑上都會結算 — 絕不卡住、絕不拒絕。 那套仲裁機制與那個追蹤器並不是被搬到別處;它們是被刪掉了。那個會 猛烈操練暫存面板的記憶體基準測試,堆積用量掉了大約三分之一。那個月 我們收到最棒的程式碼審查留言是:「這個 PR 幾乎全是紅的。」
有兩種行為從那套擁有權模型中自然浮現,而我們認為它們是任何會寫入 正式環境的工具都必備的底線:一個進行中的提交能撐過 renderer 重新載入(工作階段會重新接上並完成),它甚至能撐過你的授權在提交 途中翻成唯讀 — 新的提交會被封鎖,但一筆已經在進行中的寫入會被觀察 到它結束為止,絕不會在只套用一半的狀態下被拋棄。
為什麼傳輸格式是刻意設計得醜的
暫存的值是以原始的 DynamoDB 型別信封形式儲存 — {N: "42"}、
{S: "42"} — 而不是友善的、已 unmarshal 的 JSON。有兩個理由。diff
必須具型別意識:{N: "1"} 與 {S: "1"} 是不同的值,而一個建立在
已 unmarshal 值上的主索引鍵雜湊會讓它們相撞。而且來回轉換必須是
無損的:SDK 那些已 unmarshal 的數字包裝器,無法在序列化成 JSON 存進
SQLite 再取回的過程中存活,對使用者輸入做深度相等比較也會出錯。提交
路徑基於同樣的理由使用原始用戶端 — 你所審閱的,逐位元組就是被拿去
作為條件並寫入的東西。
然後,agent 來了
暫存機制比我們的 AI 功能更早存在,但它正是那些功能得以出貨的原因。 助理的寫入工具會進行暫存;它自己的說明會這樣告訴模型 — 逐字如下:
The user reviews + commits from the staging panel —
this tool never writes to DynamoDB directly.沒有提交工具。不是被權限管制、不是被稽核 — 而是根本不存在。 MCP server 在結構上公開了同一道 邊界:它中間那個同意 scope 的名稱,字面上就叫「read + stage」。一個 在該 scope 下的 agent 可以提議一堆 垃圾,而其波及範圍就只是一張人類會讀到的 diff 卡片。而且因為提交 帶有逐屬性的樂觀鎖,即使是一筆過時的 agent 編輯,也無法默默輾過一筆 並行的人類編輯 — 它會像其他一切一樣,以衝突的形式浮現。
我們寫過如何讓 agent 的查詢變得可靠; 暫存則是另一半 — 讓它的寫入變得無趣。
對任何會寫入你資料庫的工具,你該要求什麼
- 在意圖與寫入之間有一個審閱步驟 — 是 diff,而不是確認對話框。
- 在已審閱的快照上採用樂觀並行控制,涵蓋刪除與移除 — 絕不採用 「最後寫入者勝出」。
- 具備「失敗即停止」的原子批次,而不是遇到衝突後仍盡力續行。
- 一條能撐過當機或重新載入、而不會留下只套用一半之變更集的寫入 路徑。
- 對 AI 而言:把暫存當成 agent 唯一擁有的寫入原語 — 一個缺席的能力, 勝過一個被層層防護的能力。
暫存說明文件展示了工作流程,而 編輯 DynamoDB 資料則涵蓋了它所保護的 基礎操作。或者下載 DynoTable,用 ⌘S 暫存幾筆 編輯,親眼看著一次大量變更,變成一件你能在它真正發生之前實際讀懂的事。


