昨天讓 spec-kit 排了工、上線了一個會回 {"ok":true} 的空殼。今天要在它底下放資料庫 —— 而 schema 是規格裡最容易留下「沒說」的地方:沒說的地方,AI 得自己處理 —— 有時候替你決定,有時候乾脆留白。今天反過來利用這一點:讓一個沒看過規格的 AI 盲做一份,再跟我的對照 —— 差異會先浮出來,哪些該寫進規格、哪些只是另一種合法設計,再逐條判斷。
後 10 天我大多只定規則,程式交給 AI 寫。schema 是例外:這是我唯一先親手起稿、再去問一個什麼都沒看過的 AI 的東西。
先來說說我的版本與 AI 之間的差異。
差異從來歷就開始了。我這份是起稿之後由負責寫程式的 session 補了三條約束(一人一活動只能有一個保留中的 hold、一張 hold 只能出一張訂單、名稱長度),我審過才 commit —— 它讀過規格和測試。另一份來自無菌室:一個 git init 之後什麼都沒有的空目錄,不給 CLAUDE.md、規格、specs/、測試。不能用 clone,因為 spec-kit 在 repo 裡留下了 specs/001-…/data-model.md —— 八個實體、每個的不變條件、狀態轉換表 —— 一個新的 clone 會把它整份帶到 AI 眼前;Day 4 漏的是 .git,這次會漏的是規劃工具自己的產物。兩個 commit 差 4 分鐘,先後查得到(edb480d → b1f7ed0)。
所以今天比的是看過規格的一方,對上只拿到一句話的 AI。prompt 一個字都不提那六個決定點:
幫我設計一個活動報名系統的 SQLite schema。功能是:建活動、設票種與名額、選座位、保留一段時間、確認後出票。
它跑了 7 輪、兩分鐘。兩邊的選擇:
| # | 決定點 | 我的版本 | AI 的版本 |
|---|---|---|---|
| 1 | 金額單位 | 分,欄名 price_cents |
整數,「分或元」沒決定 |
| 2 | 名額放哪 | ticket_types.remaining + CHECK (remaining >= 0) |
沒有 remaining,trigger 當場數已佔用的 |
| 3 | 價格快照 | order_items.unit_price_cents |
reservation_items.unit_price,下單當下的價格 |
| 4 | status 約束 |
CHECK |
CHECK |
| 5 | 保留過期 | hold 上存 expires_at |
座位上存 lock_expires_at |
| 6 | STRICT |
每張表都有 | 都沒有 |
第 3、4 項,它選擇的跟我一樣。第 3 項是我原本最擔心的:只存 ticket_type_id、金額靠 JOIN 回票種表,測試全綠,但票價一改,歷史訂單的金額就跟著變。它沒走那條路,自己在欄位旁註解「下單當下的價格快照」。
另外兩項值得單獨講,因為兩種做法在真實專案裡都看得到。
名額放哪。 我把 remaining 存成欄位,再用 CHECK (remaining >= 0) 守住;它不存這個欄位,改用一支 BEFORE INSERT 的 trigger,當場 count(*) 數「已確認 + 未過期保留」的列,碰到 quota 就 RAISE(ABORT, 'sold out')(劃位票種靠座位本身的唯一鍵擋,這支只管非劃位的)。差別是:冗餘欄要自己維護,佔用和釋放都得記得改,漏一次數字就跟事實脫節;trigger 不會留下 remaining 這個冗餘值,代價是每次相關寫入都要重新計數。兩邊都靠拋錯擋 —— 這一點 Day 27 會變成重點,因為 D1 的批次只在拋錯時回滾。
過期放哪。 我存在 hold 上(expires_at);它存在座位列上(lock_expires_at),跟 reservation_id 一起判狀態:沒有 reservation_id 是空位;有的話,lock_expires_at 大於現在是保留中、小於等於現在視同空位、NULL 是已售出。它的設計說明把這招講得比我清楚:「過期是查詢時才判斷,不靠排程 —— 清理的排程沒跑也不會算錯。」代價是一張 hold 佔多個座位時,同一個到期時間會重複寫在每一列,而「一張 hold 只有一個到期時間」這件事沒有約束擋得住。
它還交了三樣我沒要的東西:一份流程 SQL 範本(保留、確認、取消、清理過期、驗票)、一支用暫存資料庫跑情境的 test.sh、一份設計說明。前面那句「過期是查詢時才判斷」就出自設計說明;流程範本裡的 BEGIN IMMEDIATE,下一節會在 D1 上碰壁。
Day 11 講過,重複的交代要寫成固定的東西,不要每次口頭講,而「檢查 schema」正是當時的例子。在這個 repo 裡,它長成的是一支測試 tests/schema.test.js:八類、11 個斷言 —— 金額是整數且欄名帶單位、時間型別一致、status 有 CHECK 而且列舉不多不少、座位用部分唯一索引、名額有非負約束、明細存單價、沒有規格以外的表、每張表 STRICT。它吃一個 SCHEMA 路徑參數,就是為了今天 —— 同一份斷言打我的版本,也打 AI 的版本。
SCHEMA=devlog/raw/exp-01-schema/schema.sql npx vitest run tests/schema.test.js
# Error: D1_ERROR: not authorized: SQLITE_AUTH
第一個問題不是測試給的,是 D1 給的。 它的第 7 行是 PRAGMA journal_mode = WAL —— 一般 SQLite 的好習慣,但 D1 不准改這個設定,整份 schema 載入失敗。prompt 只說 SQLite,它就照一般 SQLite 寫。
它的流程也用 BEGIN IMMEDIATE 開交易,而 D1 採 auto-commit,多個 statement 要一起成功或一起失敗時,用的是 db.batch()。這兩件事測試抓不到,是要真的放上平台才會炸的那種。
拿掉兩行 PRAGMA、其餘一字不改,再量一次:
我的版本 11 passed
AI 版本 4 passed | 7 failed
先講清楚紅燈是什麼:7 failed 是 7 條斷言失敗,不是 7 個缺陷。 一條斷言紅了,可能是被量的東西有問題,也可能是這份測試把某種寫法當成了唯一正解。所以我逐條看:
| 紅的那一項 | 原因 | 屬於哪一類 |
|---|---|---|
| 金額欄名帶單位 | price、unit_price、total_amount 沒寫單位 |
真的缺口:單位沒決定 |
STRICT |
六張表都沒有 | 真的缺口:沒有 STRICT,INTEGER 欄塞得進字串 |
| 部分唯一索引 | 它不用索引擋重座,鎖直接記在座位上,用條件式 UPDATE 搶 | 另一種成立的設計 |
CHECK (remaining >= 0) |
沒有 remaining 欄,trigger 當場數 | 另一種成立的設計 |
| 明細表的單價欄 | 快照有存,但表名不叫 order_items |
測試把我的實作寫死了 |
| 規格以外的表 | seats、reservations、reservation_items、tickets 不在清單上 |
測試把我的實作寫死了 |
| 列舉不多不少 | 把 is_seated IN (0, 1) 算成狀態(另外 published 不是 on_sale) |
斷言本身寫錯了 |
七條紅燈,分成四類:
STRICT。但注意,這兩條也不是 AI 做錯 —— 一句「SQLite schema」的需求裡,沒有人告訴它金額用分、這個專案每張表都要 STRICT。它們是只拿到一句需求的 AI 無從知道的決定。 AI 沒做錯,不代表 schema 沒問題。order_items、只准出現我列的那幾張表。這不只是命名問題 —— 是我的測試把實作選擇當成了需求。CHECK (x IN (…)) 都當成狀態列舉,於是一個 0 / 1 的布林旗標也被算成「狀態機以外的值」。這一條當天就修了(只收字串值,另加一個布林旗標當「不該紅」的探針),記進帳本 —— 那本帳上「我自己的裁判壞了」這一欄,到今天是第 17 筆。修完再量,AI 版仍有 7 條失敗,因為 published、held 這幾個狀態名稱本來就跟我的不一樣。測試量的是「像不像我的設計」,不是「對不對」 —— 對一個看過規格、照著我的命名寫的實作,這兩件事是同一件;對一個從零出發的,就不是。
兩個真缺口裡,代價更大的是單位。它的檔頭註解是這樣寫的:
-- 金額一律存整數(最小貨幣單位,例如「分」或「元」),避免浮點誤差。
它知道不能用浮點,也知道要存整數,然後把「分還是元」留給了下一個人。 這是誠實的留白 —— 它沒有假裝決定了 —— 但留白不會自己被填上。下一個寫結帳的人(或下一個 AI session)會各自假設一個。
上一輪的專案我量過這兩種假設的差距。同一支結算函式,只換單位:
以「元」為單位 三人均分 → 每人分攤 34 / 33 / 33
以「分」為單位 三人均分 → 每人分攤 3334 / 3333 / 3333
除不盡時,有一個人固定多付:1 元 vs 1 分
兩邊的加總都對,差的是餘數有多大 —— 差 100 倍。而且兩種存法都是合法的正整數,CHECK (>= 0) 攔不住,型別檢查也不會炸。
那今天這盞燈是怎麼亮的?靠欄名。 斷言規定金額欄要叫 *_cents,它的欄位叫 price,所以紅。換句話說,如果它把欄位叫 price_cents、心裡想的卻是元,這盞燈就不會亮。測試能檢查你有沒有把單位寫下來,檢查不了寫下來的是不是真的。 真的那一半,要到 Day 25 算錢的時候才驗得到。
還有一件事測試完全沒碰到。
我的版本時間一律存 epoch 毫秒,現在幾點由程式傳進來。它的版本存 epoch 秒,而「現在」是 SQL 自己取的 —— DEFAULT (unixepoch())、… expires_at > unixepoch(),散在 trigger、view 和流程 SQL 裡。
這很合理,也很好寫;但它等於把「現在幾點」藏進了資料庫。系列的第二條硬規則是「時間是參數,不是副作用」,而一個在 SQL 裡自己讀時鐘的 view,這套測試沒辦法穩定地把它停在「剛好過期的那一毫秒」。
出問題的全在它沒被告知的地方:平台是 D1 不是一般 SQLite、金額單位是分、每張表都要 STRICT、時間存毫秒而且要從外面傳進來。這四件事都不在那一句 prompt 裡,也都不是它該替我決定的。
分歧不在「它會不會犯錯」,在「規格沒有說的地方,它會自己決定」—— 而它的決定,有時候是留白。
一份只拿到一句話的 AI schema,在我的 11 個斷言上 7 紅,逐條看完,該紅的只有兩個:單位沒定、沒有 STRICT。另外兩個,測試根本看不到:載入 D1 會失敗、時間在 SQL 裡自己取。
對照是找空白很便宜的工具:一份盲做的 schema,兩分鐘就把六個決定點攤出來。但差異要逐條讀 —— 拿看過規格才寫得出來的測試去量它,大部分的紅燈在量「像不像」,不是「對不對」。
這一篇留下的心法:
釐清規格有兩種做法:叫 AI 把空白標出來問你(Day 21),或讓它盲做再對照(今天) —— 後者不用它開口,差異自己會說話。
但差異不等於錯:7 盞紅燈裡只有兩個是真缺口,另外兩個問題測試一條都看不到 —— 紅燈數不等於缺陷數。
明天:API 通了。契約先寫還是實作先寫,決定了契約測試有沒有裁判資格。
STRICT 資料表(3.37+):www.sqlite.org/stricttables.html
test.sh 與 run.json(7 輪、$0.50;commit b1f7ed0):github.com/n913239/event-signup-lab/tree/b1f7ed0/devlog/raw/exp-01-schema
edb480d,早於 AI 版 4 分鐘):github.com/n913239/event-signup-lab/blob/edb480d/schema.sql
tests/schema.test.js(列舉那條的修正 2c50aec):github.com/n913239/event-signup-lab/blob/2c50aec/tests/schema.test.js
55775e0 的最終版):github.com/n913239/event-signup-lab/blob/55775e0/docs/EXPERIMENT-PROTOCOL.md