今天要解的問題
昨天把杯測場次做完,順手記下三件既有的問題,今天三件都收掉:
- 店家頁的「常見風味」永遠是空的
- 按草稿「還原」之後沒再編輯就離開,草稿會被自己清掉
- README 的全新安裝 SQL 少了欄位,照它建出來的資料庫,記錄一筆都存不進去
第一件最有東西可講,前面大半篇都在講它;另外兩件各自有一個小取捨,放在後面。
先講常見風味。
這個功能長這樣——店家頁上方有一張統計摘要卡,最下面一排是這家店所有記錄裡出現最多的風味,依次數排序,最多五個:

問題是那排 chips 我在線上從來沒看過。不是排序怪、不是漏算,是整區不出現——因為 topFlavors 永遠是空陣列,而卡片對空陣列的處理是整段不渲染。
奇怪的地方在這裡:負責統計的 summarizeRecords 有一整支測試檔,十幾條,全綠。包含「只算葉節點、不算被自動選上的祖先」「風味陣列裡混進非字串也不能丟錯」這種很細的案例。算法是對的,測試也是對的,功能就是壞的。
原因是那支測試測的是純函式,而餵給它的資料是手寫的:
const s = win.summarizeRecords([
cupping({
id: 'c1',
// 選「檸檬」會連同祖先 水果類/柑橘類 一起存
evaluations: { flavor: { flavors: [
'x__l1-fruit', 'x__l1-fruit__l2-citrus', 'x__l1-fruit__l2-citrus__l3-檸檬',
] } },
observation: { aroma: { flavors: ['y__l1-floral', 'y__l1-floral__l2-茉莉'] } },
}),
// ...
]);
手寫的物件當然有 evaluations 和 observation。但真正跑在線上的時候,這兩個欄位根本沒被撈回來:
const baseCols = 'id, shop_id, coe_total, coe_tier_id, created_at';
這是 api.listRecords 的欄位清單,店家頁的記錄就是從這裡來的。evaluations 和 observation 不在裡面,於是每一筆記錄的風味清單都是空的,數出來自然也是空的。

而 summarizeRecords 對缺欄位的處理是正確的——r.evaluations 是 undefined 就跳過,不會丟錯。正確到讓這個 bug 完全沒有症狀:不會有紅字、不會有 console error,只是靜靜地什麼都不顯示。
想法與取捨
先決定:這個功能值不值得讓每一頁都變胖
最小的修法是一行:把 evaluations, observation 塞進那串 baseCols。我沒有這樣做,因為交辦的時候就先畫了一條線——改動要小,而且要「mindful of payload size」。
listRecords 不是只有店家頁在用。它同時服務三個地方:
- 記錄列表(開 app 的第一頁)
- 店家列表(要算每家店的記錄數與平均分)
- 店家頁(今天要修的這個)
前兩個從頭到尾沒碰過 evaluations。它們要的是分數、店家、日期、標題,全都在既有的欄位裡。而 evaluations 是一筆記錄裡體積最大的欄位:八個評分項目,每項各自帶 score、notes、還有一串風味 id,而風味 id 是 x__l1-fruit__l2-citrus__l3-檸檬 這種形狀的字串,選得越深越長。
所以無條件加欄位的代價是:**為了一頁用得到,讓每一次開記錄列表都多下載一份用不到的 jsonb。**這個 app 是拿手機在咖啡廳開的,列表又是第一眼看到的畫面,這個代價我不想付。
改成 opt-in:多一個預設關閉的參數,只有店家頁打開它。代價是 API 多一個參數、呼叫端要記得傳——但這個「記得傳」只有一個呼叫點,而且忘了傳的後果是功能安靜地不動,正是今天這個 bug 本身,所以我也順便讓測試把它鎖住。
還有一個更省的改法我考慮過但沒做:店家頁目前是撈「全部記錄」回來,再用 shop_id 在前端過濾——如果改成讓資料庫去 .eq('shop_id', shopId),連別家店的記錄都不用下載。沒做的原因是場次記錄本身沒有 shop_id,店家是掛在「杯」上的,這個過濾要跟場次一起設計才有意義。今天先不動它。
測試要怎麼寫才抓得到
這才是真正要解的問題。既有那些測試不是寫得不好,是它們結構上就不可能抓到這個 bug:bug 在「資料怎麼來」,而它們測的是「資料怎麼算」。再補一條純函式測試也一樣抓不到。
要抓到,測試得跨過查詢那一層,而且假的 Supabase client 必須假得夠像——像到會遵守 select。如果假 client 無論查什麼都把完整的列吐回來,那它就只是換一種方式重演同一個誤會。
repo 裡已經有假 client 的寫法可以沿用(場次的 API 測試用它來鎖寫入順序),但那個版本不管 select、直接回預先設定的資料。所以這次要多做一件事:照 select 投影。
草稿:擋掉事件,還是不要重拍 baseline
第二件事的成因是事件冒泡。草稿的還原列就長在 <form> 裡面,而自動儲存是綁在表單層的——所以「還原」按鈕的 click 會一路冒泡到自動儲存。原本的還原處理會把 baseline 重拍成還原後的內容,於是同一次點擊冒泡進去時算出「現在的值 == baseline」,自動儲存的判斷是「改回原狀就不留草稿」,300 毫秒後把草稿刪了。
按了還原、什麼都沒改就離開,草稿就消失。
最直覺的修法是在按鈕上 stopPropagation。我沒選它,因為它只擋得住這一條路徑。同一個根因還有第二條:既然 baseline 已經等於還原後的內容,那使用者「改一下又改回來」(打錯字再退格就會發生)同樣會讓兩邊相等,草稿還是被清掉。擋事件只讓症狀從兩條變一條。
改成還原後不重拍 baseline:baseline 一律維持進場時那張乾淨表單。這樣還原回來的內容跟手打的編輯是同一種東西——都是「未儲存的修改」——冒泡的那次自動儲存於是把草稿寫回去,而不是刪掉。
代價是還原後草稿會被重寫一次,savedAt 變成現在,下次進來 banner 會顯示「剛剛」,七天的過期時間也跟著重算。這個我接受:草稿確實剛剛被碰過。要保留原本的時間戳就得再讓自動儲存分辨「值沒變就不要重寫」,為了一個時間戳多一層判斷不划算。
SQL:改新寫的那段,還是改已經跑過的那段
第三件是文件跟現實對不上。README 的 section A 是「全新安裝」用的完整 SQL,但它停在某次升級之前的狀態:兩張記錄表都少了 defects_tags 和 tag_ids,也沒有 coffee.tags 這張表。而 app 存記錄時兩種模式都會寫 defects_tags,所以照 section A 建出來的資料庫,每一次新增記錄都會被擋下來,只有「先跑 A、再跑升級段」的人才會正常。
順手也發現備份表的名字對不起來:建立備份的那段寫 _backup_tasting,後面上鎖與清除的步驟寫的都是 _backup_tasting_20260826。
這裡要選的是改哪一邊。我改了建立的那段、讓它帶上日期,而不是把後面兩處的日期拿掉——因為那些 SQL 已經真的對線上資料庫跑過了,線上實際存在的就是帶日期的名字。文件要跟著已經發生的事實走,不能反過來要求現實配合文件。
實作
先看改完的 listRecords。旗標關著的時候,送出去的欄位跟以前一模一樣:
// withEvaluations:多拉 evaluations / observation(jsonb,體積大),場次內嵌的杯也一起。
// 只有店家頁的常見風味需要;記錄列表、店家列表不必為了用不到的欄位多下載。
async listRecords({ type = 'all', withEvaluations = false } = {}) {
const sb = await ensureSupabase();
if (!sb) return [];
const baseCols = 'id, shop_id, coe_total, coe_tier_id, created_at'
+ (withEvaluations ? ', evaluations, observation' : '');
// ...
呼叫端只有一個地方打開它:
const [fetched, allRecords, shopNote] = await Promise.all([
api.getShop(shopId),
api.listRecords({ type: 'all', withEvaluations: true }),
api.getShopNote(shopId),
]);
這裡有個時序上的插曲值得記一下。我動手的時候,昨天那個杯測場次還沒進 main,所以第一版只改了沖煮與品鑑兩張表。等場次進來之後才發現事情還沒完——店家頁會把場次攤成一杯一筆記錄來統計,而場次是用內嵌查詢把杯一起撈回來的,那串欄位當然也沒有風味:
if (type === 'all' || type === 'session') {
// 杯測場次沒有自己的店家 / 分數:卡片、篩選、店家頁都靠內嵌的杯摘要。
// 店家頁把每一杯當一筆記錄(flattenSessionCups),所以風味欄位也跟著旗標走。
const cupCols = 'id, code, position, shop_id, bean_name, coe_total, coe_tier_id'
+ (withEvaluations ? ', evaluations, observation' : '');
const q = sb.from(SUPABASE_CONFIG.sessionsTable)
.select(`id, title, session_date, created_at, cups:${SUPABASE_CONFIG.sessionCupsTable}(${cupCols})`);
// ...
也就是同一個 bug 有三個現場:兩張記錄表加一個內嵌查詢。只修前兩個的話,店家頁會顯示風味,但場次的杯全部不算——一個比原本更難發現的半殘狀態。
接著是假 client 的投影。PostgREST 的 select 字串裡,內嵌表寫成 cups:cupping_session_cups(id, code, ...),括號裡也有逗號,所以不能直接 split(','):
// 只切最上層逗號:內嵌的 cups:table(a, b, c) 整段要留著交給下一層。
function splitCols(cols) {
const out = [];
let depth = 0;
let cur = '';
for (const ch of cols) {
if (ch === '(') depth += 1;
if (ch === ')') depth -= 1;
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; }
else cur += ch;
}
if (cur.trim()) out.push(cur.trim());
return out;
}
然後在回應的時候照這份清單投影,遇到內嵌就去子表撈、一樣只回點名的欄位:
const EMBED_RE = /^(\w+):(\w+)\((.*)\)$/;
// ...
const cols = call.columns === '*' ? null : splitCols(call.columns);
const project = row => (cols ? Object.fromEntries(cols.map(c => {
const embed = EMBED_RE.exec(c);
if (!embed) return [c, row[c]];
const [, alias, childTable, childCols] = embed;
const keys = splitCols(childCols);
const children = (rowsByTable[childTable] || [])
.filter(child => child.session_id === row.id)
.map(child => Object.fromEntries(keys.map(k => [k, child[k]])));
return [alias, children];
})) : row);
有了這個,測試就可以直接餵「資料庫裡有什麼」,而不是「函式應該收到什麼」。店家頁那條測試的資料是兩筆記錄加一場兩杯的場次,而且第二杯故意掛在別家店:
cupping_session_cups: [
// 同一場裡兩杯掛在不同店家:只有 s1 那杯該進統計
{ id: 'cup1', session_id: 'sess1', code: 'A', position: 0, shop_id: 's1', coe_total: 86,
evaluations: { flavor: { flavors: [lemon] } } },
{ id: 'cup2', session_id: 'sess1', code: 'B', position: 1, shop_id: 'other', coe_total: 90,
evaluations: { flavor: { flavors: [jasmine] } } },
],
斷言就直接看畫面上的字:
const chips = [...root.querySelectorAll('.shop-summary-flavors .detail-flavor-chip')]
.map(el => el.textContent);
// 檸檬:c1 + t1 + 場次的 cup1;茉莉:只有 c1(c2 / cup2 是別家店)
expect(chips).toEqual(['檸檬 ×3', '茉莉 ×1']);
一條測試同時驗了三件事:風味有被算出來、場次的杯有算進去、別家店的沒有混進來。
最後一步是確認這條測試真的守得住。新寫的測試在修好的程式上通過,這件事本身沒有意義——它可能只是跟著壞的實作一起對。所以要把修正拿掉,看它會不會紅。拿掉旗標之後:
AssertionError: expected [] to deeply equal [ '檸檬 ×3', '茉莉 ×1' ]
空陣列,跟線上看到的一模一樣。
草稿那條線:四行
改完長這樣,重點是 baseline 從 let 變成 const——它不再被任何人重拍,型別就先幫你擋住往回走的路:
// baseline = 目前乾淨表單的序列化;草稿只在偏離 baseline 後才寫,
// 改回原狀則清除,避免把 pristine 表單也存成草稿。
// 還原草稿後 baseline 不重拍:還原的內容同樣是「未儲存」,若拿它當 baseline,
// 還原鈕的 click 冒泡到下面的 schedule(或改一下又改回來)就會把草稿清掉。
const baseline = JSON.stringify(build());
const existing = readDraft(key);
if (existing && existing.payload) {
showDraftBanner(form, existing,
() => apply(existing.payload),
() => clearDraft(key),
);
}
還原的回呼從「套用草稿 + 重拍 baseline」縮成只剩套用。測試用 describe.each 對沖煮與品鑑各跑一次,三個案例:
describe.each(['cupping', 'tasting'])('setupDraftAutosave(%s)', mode => {
it('按還原後沒再改就離開 → 草稿仍在', async () => { /* ... */ });
it('還原後改一下又改回來 → 草稿仍在', async () => { /* ... */ });
it('按刪除草稿 → 草稿清掉、表單維持空白', async () => { /* ... */ });
});
前兩條就是上面說的兩條路徑,第三條是護欄——確認修完之後「刪除草稿」還是真的會刪,別把 bug 修成另一個 bug。
SQL 那條線:把欄位補回去
section A 的兩張記錄表各補兩個欄位和一個索引:
defects text, -- 瑕疵自由備註
defects_tags text[] not null default '{}', -- 常見瑕疵 chip 多選
notes text,
coe_total numeric,
coe_tier_id text,
evaluations jsonb not null default '{}'::jsonb,
observation jsonb not null default '{}'::jsonb,
tag_ids uuid[] not null default '{}', -- coffee.tags.id(無 FK)
create index cupping_tag_ids_idx on coffee.cupping_records using gin(tag_ids);
tag_ids 和 coffee.tags 目前 app 根本沒用到——補進去的理由不是功能,是線上資料庫已經是這個樣子。全新安裝的 SQL 跑完,應該要得到跟線上一致的 schema,不然下次有人(包括未來的我)照它建一套來重現問題,會發現兩邊行為不一樣,而且找不出為什麼。所以連內建標籤的 seed 也一起補齊,註解直接寫明「目前 app 沒用到,保留與既有資料庫一致」。
踩到的坑
一行 git 把自己的修正也還原掉
上面那個「拿掉修正跑一次」的驗證,我是這樣做的:先 cp 一份備份,用 sed 把旗標改回去,跑測試,然後——
git checkout -- app.js
問題是那個時候修正還沒 commit。git checkout -- 還原的是「工作區相對於 HEAD 的所有改動」,不是「我剛剛那次 sed」。所以它很盡責地把驗證用的暫時改動和真正的修正一起丟掉了。
救回來靠的是前一步那份備份,但備份本身也有故事:我寫的是
cp app.js "$TMPDIR/app.js.bak" 2>/dev/null || cp app.js ../app.js.fix-bak
而 Git Bash 裡 TMPDIR 是空的,所以第一個路徑其實是 /app.js.bak,寫不進去;真正救了我的是 fallback 那份 ../app.js.fix-bak。等於是兩個錯誤互相抵銷。
之後的做法改成:要驗證「拿掉修正會不會紅」,先 commit 再動手。 commit 之後 git checkout -- 就是安全的,備份也不再是唯一的救命繩。後面補場次那一段時就是這樣做的,備份也改寫到明確的絕對路徑。
一次拿掉兩個旗標,等於什麼都沒驗到
補完場次的杯之後,我要驗證「cups 這半也守得住」,於是又跑一次同樣的流程,用 sed 把旗標那行的結尾換掉:
sed -i "s/+ (withEvaluations ? ', evaluations, observation' : '');\$/;/" app.js
這個 pattern 同時命中了 baseCols 和 cupCols 兩處,於是兩半的欄位一起消失,chips 直接變成 []。測試是紅了,但紅得沒有資訊——它只證明「兩個都拿掉會壞」,沒有證明「cups 那半有在做事」。
改成只動 cupCols 那兩行之後,才拿到真正想看的對照:
AssertionError: expected [ '檸檬 ×2', '茉莉 ×1' ] to deeply equal [ '檸檬 ×3', '茉莉 ×1' ]
×2 是兩筆一般記錄貢獻的,少掉的那個 ×1 就是場次那杯。這一行差異才是「內嵌查詢的欄位確實有生效」的證據。
教訓是:驗證一個修正時,一次只拿掉一個東西。sed 的 pattern 很容易比你以為的命中更多行,而測試變紅的原因如果不只一個,那次驗證就等於白跑。
一條線先進去,另外幾條的地基就換了
這三件事是各自獨立進行的,起點都是昨天的 main。杯測場次先進去之後,另外幾條的地基就變了——而且變動的位置一點都不隨機。
草稿那條最明顯:它的修正動的是 setupDraftAutosave 裡的 baseline 那行和還原回呼那行,而場次為了共用同一套草稿機制,正好把這個函式改成吃 build() / apply() 兩個回呼(場次表單要傳自己的版本進去),動到的也是那兩行。衝突的位置就是改動的位置,因為兩邊解的是同一個函式的不同問題。
這種衝突不能選邊。留 main 的那版,修正就不見了;留修正的那版,場次表單的草稿就壞了。正確解是保留 main 的參數化,再把「不重拍 baseline」的語意套上去——也就是上面貼的那段 const baseline = JSON.stringify(build())。
常見風味這條線也吃到同一件事,只是形式不同:它沒有文字衝突,但場次進來之後,「店家頁要統計的記錄」多了一種來源,於是修正本身變得不完整。沒有紅字的那種才麻煩。
另外值得記的是我查狀態的方式錯了:我第一次去看草稿那條線時,看到的是還沒更新的本地資料,判斷它卡在衝突上;重新 fetch 之後才發現那條線自己已經把 main 併回去、也解好了。一個 git fetch 就能省下的誤判。
小結
昨天記下的三件都收掉了:常見風味在店家頁會顯示,而且三種來源都算得到(沖煮、品鑑、以及杯測場次裡的每一杯);按草稿「還原」之後不會再被自動儲存清掉,沖煮與品鑑各三條測試守著;全新安裝的 SQL 補回 defects_tags / tag_ids / coffee.tags,照它建出來的資料庫現在存得進記錄了。lint 與測試都綠,27 個檔案、318 條。
要誠實講兩件事。一是今天整條驗證都在 jsdom 裡跑,沒有連過真正的 Supabase——本機沒有 config.js,正式站要 Google 登入。假 client 模仿的是「PostgREST 會照 select 投影」這個行為,這個前提我有信心,但「線上那家有記錄的店家頁真的會出現 chips」還是得親眼看一次,這是明天第一件事。
二是補回欄位的那段 SQL 同樣沒有真的建一個空資料庫跑過(手邊沒有 Postgres 也沒有 Docker),驗證方式是拿升級段逐欄比對,再對照 app 實際會寫進去的每一個欄位。這比「看起來對」強,但還不是「跑過」。
另外有個明擺著的缺口留著:README 的全新安裝 SQL 裡,場次的杯那張表有 defects_tags 但沒有 tag_ids,而兩張記錄表現在兩個都有。同一組標籤欄位在三張表上長得不一樣,遲早會咬人。
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day07-empty-flavor-chips/
