系統地圖 大計劃 #P-FIX-1 Per-Restaurant Container
#P-FIX-1 · ⚠️ 災難級重構 · IN PROGRESS

多餐廳資料隔離 — 一家餐廳一個 sqlite 重構

老闆 2026-05-16 dogfood 抓到災難:海邊小巫 14 個桌位被搬到溪邊。 這份計劃是根治方案 — 改採業界 B2B SaaS 主流做法(Slack / Notion / Salesforce / QuickBooks 同款), 每家餐廳獨立 sqlite,從檔案系統層級隔離,根除跨餐廳污染。

🤖 提案 / 實作:Claude (AI 員工) 📅 啟動日:2026-05-16 📂 類型:架構級重構(會寫 code) ⏱ 預估工時:1-2 個工作週期 ✅ 老闆已綠燈:2026-05-16

📊 整體進度

Phase 1-7
95%

2026-05-20 update:Main & Booking runtime swap 補完 + cleanupForeignRows 掛 boot 完成。剩下只有 Phase 6 deferred (移除冗餘 filter 等新架構穩定後再清,無害)。

📑 目錄

  1. 1. 為什麼做這個更新
  2. 2. Audit 發現了什麼
  3. 3. 三種方法評估 + 業界做法
  4. 4. 最終選擇:方案 A
  5. 5. 設計總覽
  6. 6. 7 個 Phase + 細項進度
  7. 7. 給工程師看的技術細節

1. 為什麼做這個更新

老闆,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 件事:

  1. 立刻恢復資料(直接 SQL 改回 14 筆 floor_items)
  2. 找到 root cause(self-heal RLS 錯誤無條件改 restaurant_id)+ 修補
  3. 提這份計劃讓系統不會再壞掉

— Claude,2026-05-16 災難當天

2. Audit 發現了什麼

🪲 真正的 root cause(已修但只是堵漏)

ChefsMateShared/.../Sync/Core/Syncable.swiftGenericSyncExecutor.upload 自我修復邏輯:

// ❌ 災難版 if RLS_42501 || FK_23503 error { repairable.repairRestaurantId(currentRestaurantId) retry upload // ← 等於資料搬家 }

觸發鏈

  1. user 切到 B 餐廳(currentRestaurantId = B
  2. A 餐廳的某個 model 被 markForUpload(mapper 更新 / sheet markForSync)
  3. 上傳 → server RLS 拒絕(user 在 B context,A row 屬於 A)
  4. self-heal 改 model.restaurantId = B
  5. retry upsert 成功 → server row 永久變成 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 就能再次引爆。必須換結構。

3. 三種方法評估

我去查了 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) 只當前可
📌 業界主流是 A,不是 B 或 C

B2B SaaS 處理多 tenant 的標準答案就是「per-tenant 獨立 storage」。 我們的 CLAUDE.md 已經有「每個 App 一個 sqlite」的概念 (ChefsMate.sqlite / ChefsMatePOS.sqlite / ChefsMateBooking.sqlite), 這次只是再下一層自然延伸ChefsMatePOS-海邊.sqlite / ChefsMatePOS-溪邊.sqlite

4. 老闆最終選擇:方案 A

✅ 老闆 2026-05-16 拍板

「我選 A,直接開始做,不用等到我睡覺」

選 A 的理由(業界已驗證):

5. 設計總覽

▼ 新架構:1 餐廳 1 container
───────────── 目前(單一 store) ───────────── ChefsMatePOS.sqlite ← 全部混在一起 ├─ floor_items: [海邊..., 溪邊...] ├─ business_hours: [海邊..., 溪邊...] └─ ... ───────────── 新架構(per-restaurant) ───────────── ChefsMatePOS-海邊.sqlite ← 只有海邊資料 ├─ floor_items: [海邊...] └─ ... ChefsMatePOS-溪邊.sqlite ← 只有溪邊資料 ├─ floor_items: [溪邊...] └─ ... ChefsMatePOS-shared.sqlite ← 跨租戶資料 ├─ user_profiles: [...] ├─ customer_credit_scores: [...] ← #W2 跨餐廳信用分數 └─ sync_deletions: [...] ← 跨餐廳刪除信號
▼ 切換流程
user 點切換 (海邊 → 溪邊): 1) 等 pending sync 完成(避免遺失未上傳) 2) AuthManager.activeContainer = containers[溪邊] ├─ 若不存在 → 先 createContainer(restaurantId: 溪邊) └─ 若存在 → 直接 swap(瞬間) 3) UI 重新 render @Query 4) 觸發 incremental pull for 溪邊(後台) 5) 完成

🔑 4 層架構

檔案職責
GateRestaurantContainerGates.swift純函數:canSwitchTo(rid) / shouldWaitForPending(...)
PrimitiveRestaurantContainerRegistry.swift單一副作用:createContainer / getContainer / disposeContainer
OrchestratorRestaurantSwitchOrchestrator.swift組合:waitPending + swap + triggerPull
App / ViewChefsMatePOSApp.swift監聽 currentRestaurantId,注入 activeContainer 給 SwiftUI

6. 實作 Phase(即時更新)

1
架構設計 + Entity Audit(shared vs per-restaurant)
DONE
重大發現:68 個本地 @Model entity 都是 per-restaurant scope! user_profiles / sync_deletions / customer_credit_scores 等跨租戶資料只存在 supabase,不在本地 SwiftData。 架構大幅簡化:只需 per-restaurant container,不需 shared container

Entity 分類文件:ChefsMateShared/.../Backend/Data/Sync/Core/RestaurantContainerScope.md
2
核心:RestaurantContainerRegistry
DONE
✅ 中央 registry + ActiveContainerHolder + Gates + Orchestrator 完成。 Lazy create + LRU 10。Per-restaurant file naming: ChefsMatePOS-{rid-no-hyphens}.sqlite
3
3 個 App 整合(全部含 runtime swap)
DONE
✅ 3 個 App 都接通 Registry + Migrator。2026-05-20:Main & Booking 補完 runtime swap(同 POS pattern: @StateObject containerHolder + .id() force re-create + .modelContainer(holder.current) + .onReceive(AuthManager.$currentRestaurantId) 觸發 swap + reconfigureSyncContextsOnSwap)。 切換餐廳不用重啟,即時生效。
4
既有資料遷移(LegacyContainerMigrator)
DONE
✅ LegacyContainerMigrator.swift — 首次升級的 user 自動把舊 ChefsMatePOS.sqlite 複製為 current rid 的 per-rid file,其他餐廳走 lazy create + full pull from server。 舊 sqlite rename 為 .legacy.backup 保留 (disaster fallback)。 Flag 防重跑。
5
Shared entities 設計(N/A — 不需要)
N/A
Phase 1 audit 發現本地沒有任何 entity 是跨租戶 shared。 跨租戶資料(user_profiles / sync_deletions / customer_credit_scores)只存 supabase, 不在本地 SwiftData。所以不需要 shared container。
6
守則更新 + 冗餘 filter 清理(延後)
PARTIAL
sync-safety-check skill 加 Section 0 (per-restaurant container 規則)。 BusinessHours / SpecialDates 之前手動加的 filter 暫時保留(無害且多一層防護), 確認新架構穩定後再清。
7
3 apps build + dogfood checklist + 老闆驗收
DOGFOOD
2026-05-20 update:3 個 App 都已含 runtime swap(POS / Main / Booking), 切換餐廳即時生效不用 restart。等老闆 dogfood 三家 app 驗收切換不污染。

7. 給工程師看的技術細節

7.1 Container 命名規則

ChefsMatePOS-{restaurantId}.sqlite         // per-restaurant
ChefsMatePOS-shared.sqlite                 // 跨租戶

restaurantId 用 UUID lowercase 去 hyphen,避開檔名特殊字元
e.g. "ChefsMatePOS-5fa79171c0ff400e8a67a7efb9b15c08.sqlite"

7.2 Entity 分類規則

分類依據: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)

7.3 SwiftUI 動態 ModelContainer

// 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
}

7.4 Migration 流程(首次升級)

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. 完成 → 繼續正常啟動

7.5 跨 container 的 query 怎麼辦?

「跨餐廳列表」(罕見場景,如管理員看所有餐廳的 today 總營收):

  1. iterate restaurantIds → 各自 container fetch → 合併 in memory
  2. 或用 Supabase 直接 query(cloud-side aggregate)

不做 cross-container SQL join。如果一個 feature 需要 join → 該 feature 走 cloud-side。

7.6 風險與緩解

風險緩解
切換 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
📅 計劃 v1 (2026-05-16) · ⚠️ 進行中 · 每 phase 完成會更新此頁進度條
💬 想加意見?許願池