老闆 2026-05-16 dogfood 抓到災難:海邊小巫 14 個桌位被搬到溪邊。 這份計劃是根治方案 — 改採業界 B2B SaaS 主流做法(Slack / Notion / Salesforce / QuickBooks 同款), 每家餐廳獨立 sqlite,從檔案系統層級隔離,根除跨餐廳污染。
2026-05-20 update:Main & Booking runtime swap 補完 + cleanupForeignRows 掛 boot 完成。剩下只有 Phase 6 deferred (移除冗餘 filter 等新架構穩定後再清,無害)。
老闆,2026-05-16 下午你 dogfood 抓到:
「我的手機一直跑去溪邊小巫,營業日只剩星期二三,海邊的桌位都不見了,溪邊反而有很多桌位。 我不要這麼不穩定的系統,用個兩三天就全部壞掉。」
我去 supabase 查證據 — 14 筆海邊小巫的 floor_items 真的被改成 restaurant_id=溪邊。
那一批的 updated_at 全部是 2026-05-16 06:28 UTC,
就是我 ship #B1 floor item soft-delete 後 40 分鐘內。
我做了 3 件事:
— Claude,2026-05-16 災難當天
ChefsMateShared/.../Sync/Core/Syncable.swift 的
GenericSyncExecutor.upload 自我修復邏輯:
觸發鏈:
currentRestaurantId = B)model.restaurantId = B| 問題 | 影響 | 嚴重度 |
|---|---|---|
| 單一 sqlite store 共用 | 所有餐廳 row 混在一份檔案,靠 restaurant_id 欄位區分 |
⚠️ 災難級 |
| 25+ @Query 沒 restaurant_id filter | 跨餐廳 row 混進 view,user 看混合資料、編輯誤觸 sync | ⚠️ 高 |
| self-heal 無條件改 restaurant_id | 已修(commit 33cf0698)但只是堵漏,沒拆地雷 | 中(已堵) |
| AuthManager 自動切餐廳非確定性 | App 啟動隨機選擇餐廳,user 看到「跳到別家」 | 中(前已修) |
self-heal 只是地雷的觸發機制。地雷本身是「所有餐廳 row 共處一個 store」。 只要這個結構在,任何工程師寫錯一個 query 就能再次引爆。必須換結構。
我去查了 B2B SaaS 業界怎麼處理多 tenant 的 local cache:
| 方案 | 業界例子 | 污染風險 | 切換速度 | 離線 | 工程紀律要求 |
|---|---|---|---|---|---|
| A. 獨立 store / 獨立 sandbox 一家餐廳一個 sqlite |
Slack / Notion / Microsoft Teams / Salesforce / QuickBooks / Discord / Figma | 0(檔案隔離) | 瞬間 swap | 每家都可 | 低 |
| B. 單一 store + 每 query 都 filter 我們現在的做法 |
少數新創早期 / 舊版 Trello | 高(漏 filter 必爆) | 瞬間 | 都可 | 超高(永遠有人寫錯) |
| C. Purge on switch 切換時砍掉非當前 row |
金融 / 合規類少數 app | 低(switch 才清) | 慢(要 re-pull) | 只當前可 | 中 |
B2B SaaS 處理多 tenant 的標準答案就是「per-tenant 獨立 storage」。
我們的 CLAUDE.md 已經有「每個 App 一個 sqlite」的概念
(ChefsMate.sqlite / ChefsMatePOS.sqlite / ChefsMateBooking.sqlite),
這次只是再下一層自然延伸:
ChefsMatePOS-海邊.sqlite / ChefsMatePOS-溪邊.sqlite。
「我選 A,直接開始做,不用等到我睡覺」
選 A 的理由(業界已驗證):
| 層 | 檔案 | 職責 |
|---|---|---|
| Gate | RestaurantContainerGates.swift | 純函數:canSwitchTo(rid) / shouldWaitForPending(...) |
| Primitive | RestaurantContainerRegistry.swift | 單一副作用:createContainer / getContainer / disposeContainer |
| Orchestrator | RestaurantSwitchOrchestrator.swift | 組合:waitPending + swap + triggerPull |
| App / View | ChefsMatePOSApp.swift | 監聽 currentRestaurantId,注入 activeContainer 給 SwiftUI |
user_profiles / sync_deletions / customer_credit_scores
等跨租戶資料只存在 supabase,不在本地 SwiftData。
架構大幅簡化:只需 per-restaurant container,不需 shared container。
ChefsMateShared/.../Backend/Data/Sync/Core/RestaurantContainerScope.md
ChefsMatePOS-{rid-no-hyphens}.sqlite。
@StateObject containerHolder + .id() force re-create + .modelContainer(holder.current) +
.onReceive(AuthManager.$currentRestaurantId) 觸發 swap + reconfigureSyncContextsOnSwap)。
切換餐廳不用重啟,即時生效。
ChefsMatePOS.sqlite
複製為 current rid 的 per-rid file,其他餐廳走 lazy create + full pull from server。
舊 sqlite rename 為 .legacy.backup 保留 (disaster fallback)。
Flag 防重跑。
ChefsMatePOS-{restaurantId}.sqlite // per-restaurant
ChefsMatePOS-shared.sqlite // 跨租戶
restaurantId 用 UUID lowercase 去 hyphen,避開檔名特殊字元
e.g. "ChefsMatePOS-5fa79171c0ff400e8a67a7efb9b15c08.sqlite"
分類依據:DB schema 是否有 restaurant_id 欄位 + 是否多餐廳共享。
| 類型 | 例子 | 放哪 |
|---|---|---|
| per-restaurant(有 restaurant_id) | FloorItem / BusinessHours / SpecialDate / RestaurantSettings / Asset / Reservation / Order / MenuItem ... (~50 個) | per-restaurant container |
| cross-restaurant shared(跨租戶) | user_profiles / sync_deletions / customer_credit_scores (W2 跨餐廳) | shared container |
| group-scoped(集團共享) | visibility=group + group_id 的 entity(Brand / Vendor 等) | shared container(FK 不跨 container) |
// ChefsMatePOSApp.swift
@StateObject private var auth = AuthManager.shared
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(currentContainer) // ← 切換 currentRestaurantId 自動 swap
.environment(\.sharedModelContainer, sharedContainer)
}
}
private var currentContainer: ModelContainer {
if let rid = auth.currentRestaurantId {
return RestaurantContainerRegistry.shared.getOrCreate(rid)
}
return RestaurantContainerRegistry.shared.bootstrap // 未登入用空 container
}
1. App 啟動 → 偵測 UserDefaults flag "migrated_to_multi_container_v1" == false
2. 偵測舊 ChefsMatePOS.sqlite 存在
3. 建 legacy ModelContainer 讀舊 store
4. 拿到所有 user 的 restaurantIds (AuthManager.memberships)
5. for each rid: createContainer(rid)
6. for each Syncable entity:
- fetch all from legacy container
- group by restaurantId
- insert into corresponding new container
7. shared entities → insert into shared container
8. flag = true, 舊 sqlite rename .legacy.backup
9. 完成 → 繼續正常啟動
「跨餐廳列表」(罕見場景,如管理員看所有餐廳的 today 總營收):
不做 cross-container SQL join。如果一個 feature 需要 join → 該 feature 走 cloud-side。
| 風險 | 緩解 |
|---|---|
| 切換 container 時 @Query 沒重新觸發 | 用 id() modifier force re-create view tree |
| Migration 失敗導致資料消失 | 舊 sqlite 永遠保留 .legacy.backup;migration 失敗時 fallback 走舊 container |
| 磁碟空間(N 家餐廳 = N 倍 sqlite) | 每個 sqlite 通常 < 50MB,N=10 也才 500MB。可接受。 |
| pending upload 在 switch 時遺失 | orchestrator 先 await pending sync 才 swap |
| Backend Realtime channel 切換 | HybridSyncManager 監聽 currentRestaurantId 切換時 unsub + resub |