為什麼我們將保管箱的 SQLite 資料庫放在 OPFS,而非 IndexedDB
閱讀時間 8 分鐘 作者 NT²
SQLite 需要的是檔案。隱私保管箱需要耐用的關聯式儲存,而且隨著項目、索引與加密附件增加, 效能仍須保持穩定。以 IndexedDB 承載 SQLite 虛擬檔案系統,確實能連接這兩個世界, 但這座橋也會介入每一次資料庫操作。對 NT² Vault 而言,瀏覽器中的保管箱檔案應該放在 Origin Private File System。
為什麼我們將保管箱的 SQLite 資料庫放在 OPFS,而非 IndexedDB
我們的立場很直接:瀏覽器保管箱的主要 SQLite 資料庫應該放在 Origin Private File System(OPFS,來源私有檔案系統),而非 IndexedDB。
這不代表 IndexedDB 不好。IndexedDB 是成熟的瀏覽器資料庫,擁有廣泛的相容歷史。它很適合結構化記錄、鍵值狀態、快取,以及小型本地索引。只要資料語意與工作相符,我們仍然會使用它。
但加密保管箱不是一個小型 object store。它是一套關聯式系統,需要交易、外鍵、schema 演進、分頁查詢與全文搜尋。SQLite 已經知道該如何提供這些能力。真正的問題是:要讓 SQLite 在接近檔案的儲存系統上運作,還是要透過另一套交易式資料庫來模擬它的檔案操作?
我們試過第二種架構。它足以證明本地優先架構可行,但「可以運作」不等於「適合作為長期儲存邊界」。
限制:SQLite 疊在 IndexedDB 上,必須持續支付語意轉換成本
WebAssembly 不會自動為 SQLite 帶來耐用的網頁檔案。SQLite 會透過 Virtual File System(VFS,虛擬檔案系統)與執行環境溝通。瀏覽器 VFS 必須把 SQLite 所期待的操作——開啟檔案、從特定位移讀取 bytes、寫入 pages、截短、同步、鎖定與關閉——轉換成瀏覽器實際提供的 API。
IndexedDB-backed VFS 會把這些操作轉換成 IndexedDB 記錄與 transactions。這是很巧妙的工程,也讓不提供傳統檔案系統存取能力的瀏覽器,能實際執行具持久性的 SQLite。
問題在於兩邊的語意差距。
SQLite 的思考單位是 database pages、journals、locks 與 durability barriers。IndexedDB 的思考單位則是 object stores、keys、values 與非同步 transactions。VFS 必須持續將其中一套模型映射到另一套。每一層都有自己的 transaction 生命週期與失敗行為,因此 adapter 不只是儲存位置,更成了一層「資料庫裡再放一個資料庫」的翻譯機制。
對小型資料集或有限的查詢模式而言,這項成本或許可以接受。保管箱承受的壓力更大:
- 建立與編輯項目時,需要具原子性的關聯式更新;
- 清單檢視需要穩定分頁,而不是將所有項目一次載入記憶體;
- 全文搜尋索引需要持續更新,並維持有效率的讀取;
- schema 變更需要可預期的 transaction 行為;
- 鎖定、關閉、重新開啟、備份與刪除路徑,都必須對「何謂耐用」有一致答案;
- 加密附件的中繼資料,必須與分開儲存的密文保持一致。
效能只是一半,另一半是耐用性。若 storage adapter 為了配合 host API,而使 SQLite 必須採用較寬鬆的設定或犧牲 journal 行為,應用程式就放棄了當初選擇 SQLite 的部分理由。保管箱不該在桌面環境擁有一種已提交 transaction 的定義,到了瀏覽器卻因檔案是透過 object database 模擬,就接受另一種較弱的定義。
更深層的結論不是「更努力最佳化 IndexedDB」,而是我們的關聯式資料庫應該建構在最接近私有應用程式檔案語意的瀏覽器 primitive 上。
設計:wa-sqlite 搭配協作式同步 OPFS VFS
OPFS 是以 origin 為範圍的私有檔案系統。它不會像一般文件一樣呈現在使用者面前,而且某個網站無法瀏覽另一個 origin 的儲存空間。對安裝式或在瀏覽器中執行的本地優先應用而言,這正是私有 runtime database 所需要的邊界。
NT² Vault 透過 wa-sqlite,以 WebAssembly 執行 SQLite。在瀏覽器中,專用 Web Worker 擁有資料庫,並透過協作式同步 VFS 連接 OPFS。每個保管箱都有一條實際存在的資料庫路徑:
vaults/{vaultId}/vault.sqlite
加密附件 frames 則放在同一保管箱的私有目錄中:
vaults/{vaultId}/attachments/{attachmentId}.{chunkIndex}.bin
SQLite 仍是關聯式中繼資料的權威來源。附件密文則保留在適合 binary I/O 的檔案中。這項分工是刻意的:資料庫可以查詢附件名稱、大小、所屬項目與加密中繼資料,而不必把大型加密 binary 變成資料庫 rows 或 IndexedDB values。
這套架構具有清楚的擁有關係:
flowchart LR
UI[SvelteKit UI] --> RPC[Typed worker RPC]
RPC --> Worker[專用 Web Worker]
Worker --> SQLite[wa-sqlite WASM]
SQLite --> VFS[協作式同步 OPFS VFS]
VFS --> DB[(vault.sqlite)]
Worker --> Blobs[加密附件檔案]
Worker 邊界之所以重要,有兩個原因。
第一,同步 OPFS access handles 只能在 workers 中使用,不能放在瀏覽器 main thread。VFS 可以執行近似檔案的讀取、寫入、截短、flush 與關閉,而不必把每一個低階 SQLite 操作都變成 application-level 的非同步流程。
第二,資料庫工作不應阻塞畫面 rendering 或使用者輸入。SQL 執行與 filesystem I/O 留在 typed message 邊界後方,介面則維持靈敏。應用程式的 repositories 與 state layer 不需要知道實體檔案位於 OPFS,還是原生桌面目錄;它們都透過同一套 storage contract 溝通。
這不代表每個分頁都應該開啟檔案,然後競相寫入。單一寫入者擁有已解鎖保管箱的 database handle。其他分頁跟隨寫入者,而不是自行創造第二條 mutation path。OPFS 提供合適的檔案語意;明確的擁有權則提供合適的 concurrency 語意。
在這條路徑上,我們也採用 SQLite 完整的耐用性設定。Commit 應該代表 SQLite 已完成它所預期的耐用性工作,而不是 adapter 以一組方便的 object-store writes 近似完成。
IndexedDB 仍有工作,只是不再負責 SQLite 的工作
當我們期待每一種 storage primitive 在所有比較中都勝出,儲存選擇就容易變成意識形態。這不是我們的設計方式。
在任何保管箱解鎖之前,裝置需要一個小型保管箱選擇器。它包含 display name 與不透明保管箱識別碼等 device-local entries。這是少量、簡單的結構化狀態。它不是權威保管箱 profile,也不需要 SQL joins、全文搜尋或 file-oriented I/O。
IndexedDB 很適合這項工作。
這項區分非常重要:
- IndexedDB 回答:「這台裝置可以在選擇器中提供哪些本地保管箱?」
- OPFS-backed SQLite 回答:「這個保管箱內有什麼,以及它的耐用關聯式狀態是什麼?」
將選擇器分開,也能避免循環依賴。應用程式可以在開啟任何保管箱資料庫之前,先找出裝置上的本地保管箱。使用者選定之後,應用程式再開啟該保管箱位於 OPFS 的獨立 SQLite 檔案。
同時使用兩種瀏覽器儲存 API 並不矛盾,而是依照每種 API 自然支援的資料模型來分配工作。
取捨:真正存在的瀏覽器支援門檻
OPFS 設計有其代價:它需要現代瀏覽器正確支援同步 access-handle 行為。
關鍵不只在於 navigator.storage.getDirectory() 是否存在,甚至也不只在於瀏覽器有沒有暴露 createSyncAccessHandle()。同步 VFS 所使用的方法——例如讀取、寫入、截短、flush 與檢查檔案大小——必須真的以同步方式運作。
Safari 與 iOS 是很實際的 edge case。16.4 以前的版本曾提供較早期的 API 形式,其中部分操作會回傳 promises。表面上的 feature check 可能認為該 API 已受支援,但它無法滿足同步 SQLite VFS。若繼續執行,這項不相容可能要等到資料庫開啟或建立 schema 時,才以模糊的 disk I/O failure 出現。
我們選擇誠實的相容性邊界。Worker 會在 SQLite 接觸 VFS 之前,先探測必要行為。若 access-handle methods 不具備我們所需的語意,保管箱就不會開啟,應用程式會顯示清楚的瀏覽器支援錯誤。
因此,在 Apple 平台上,Safari 16.4 或更新版本是這條 storage path 的實際門檻。其他瀏覽器也必須在 worker 中提供具同步 access handles 的 OPFS。
我們不會悄悄 fallback 到舊有的 IndexedDB VFS。
Fallback 聽起來對使用者很友善,但在這裡會產生兩種耐用性模型、兩種效能特徵,以及兩套與儲存方式相關的失敗模式。使用者可能在不知情的情況下,於舊路徑建立保管箱,之後又因瀏覽器版本或 capability detection 而遇到不同的行為。支援與復原將變得更難預測,而這恰好是最需要可預測性的地方。
嚴格的 capability check 不如假裝所有 storage backends 都相同來得方便,卻更加誠實。
我們拒絕的做法:為了好看而 dual-write 的遷移戲碼
要讓 storage 變更看起來毫無痛點,最誘人的做法是把每次 mutation 同時寫入兩套系統、加入 background converter、維護 dual-read 邏輯,然後宣稱沒有人會注意到變更。
對加密的本地保管箱而言,這種做法帶來的風險大於安心感。
Dual write 代表每一次建立、更新、刪除、附件變更與 schema transition,都必須在兩套具有不同 transaction 語意的 persistence systems 中成功。若一邊成功、另一邊失敗,應用程式就需要 reconciliation rules。若瀏覽器在兩次寫入之間關閉,還需要 recovery rules。若兩份副本都留下,刪除與資料保留行為也會更難解釋。Migration machinery 最終成為產品內部長期存在、卻被隱藏起來的第二套 storage engine。
那不是耐用性,而是模糊性。
NT² Vault 將 OPFS 視為全新的瀏覽器保管箱位置。我們不會悄悄讀取舊的 IndexedDB-backed SQLite database、在使用者不知情的情況下複製,再讓兩條路徑長期共存。需要將資料帶到全新保管箱環境時,可攜式備份與匯入才是明確的復原橋梁。
我們能做出這項選擇,是因為產品當時仍在建立瀏覽器 storage foundation。若成熟產品已經承載多年使用者資料,遷移義務當然不同。這裡的一般原則不是「永遠不要遷移」,而是「不要只為了迴避一次明確的切換,就建立永久的雙重儲存架構」。
現在,瀏覽器保管箱檔案就是 OPFS 裡的檔案。一個真相來源、一種耐用性模型、一條刪除邊界。
本地優先保管箱所需要的檔案導向基礎
只將部分狀態放進瀏覽器 API,再加上一個離線標章,並不會自動成為本地優先。要讓本地系統真正成為主要系統,它必須足夠一致:具備 transactions、可搜尋、可復原,並清楚說明相容性限制。
對 NT² Vault 而言,這代表讓 SQLite 保持 SQLite 原本的運作方式。wa-sqlite 提供關聯式引擎;專用 worker 讓資料庫工作離開介面 thread;協作式同步 OPFS VFS 則為引擎提供符合其設計的檔案語意。IndexedDB 仍留在架構中,但只負責它最自然擅長的小型裝置索引。
這樣的結果不像 universal storage abstraction 那麼神奇,而這正是優點:每一條邊界都清楚說明自己擁有什麼。
若想了解更完整的架構,包括本地加密、離線運作與可選的 blind edge,請閱讀為什麼選擇 PWA 本地優先、零伺服器的 Vault。
如果這種信任模型符合你對隱私保管箱的期待,可以前往 nt2.me 進一步了解。
最後更新 2026-08-01
相關故事
- 為什麼選擇 PWA 本地優先、零伺服器的 Vault
閱讀時間 5 分鐘
- 無法重設密碼,是刻意的設計
閱讀時間 8 分鐘
- 在邊緣進行盲目 replica 同步
閱讀時間 8 分鐘