1M 列批次建立改用瀏覽器內 SQLite(souffle !1199 + cupcake !1777)

研究這組前後端 MR 的完整筆記,抽出的原子筆記見文末。 MR 連結

一、原本的瓶頸

舊 validate 回傳 { rawMaterials, activityData, validateResults } 整包 JSON,前端 transformApiResponseToForm 一次把全部列轉成 form row 灌進 zustand。瓶頸是複合的:

  • HTTP:100 萬列的 JSON 是 GB 級,ingress 有 proxy-body-size 上限,根本傳不動
  • 記憶體:每列被展開成含 {label, value} 的 form object,實際 footprint 遠大於原始資料
  • CPU:transform 在主執行緒同步跑,整段期間 UI 凍住
  • 同步 request:validate 要等後端跑完整包才回應,長連線容易 timeout
  • 另外 useSorting 是全陣列排序再切片;tableColumns 的 memo deps 含 sortedIndices / pageIndex / pageSize,翻頁就重建所有 column def → 50×30 個 cell 全重渲染

二、解法:SQLite artifact 當傳輸格式與儲存層

把「資料的儲存與查詢」整個搬出 JS heap 與主執行緒:

  • 後端產 .sqlitebetter-sqlite3)→ gzip → 上傳 GCS → 前端下載
  • 前端在 Worker 裡用 sql.js(SQLite 編成 WASM)打開ORDER BY / LIKE / LIMIT OFFSET / COUNT 全部下推成 SQL,主執行緒只持有當前這一頁 50 列
  • errors 表由 Worker 在 queryPage 裡 join 回來,不再另外回一個 validateResults 陣列
  • validate / submit 改成非同步 workflow:202 + workflowId → 輪詢 status → 下載 artifact
  • externalQuery optional prop 而非改寫 BulkInputTable:有傳就走 Worker 分頁,沒傳走原路徑,其他 consumer 零影響
  • 舊流程整份保留為 *Legacy,可回退

前端的心智模型:SQLite 是唯一真相,zustand 降級成單頁的顯示快取

  • 讀:完全走 SQL,paginatedData 直接 return { rows: externalQuery.pageRows }
  • 寫:write-through — store 先更新讓 UI 不卡,SQLite 隨後同步;真正被 export 送給後端的是 SQLite 的內容,不是 store
  • 顯示:cell 渲染、欄位設定、驗證、i18n 還是原本的 React 那套
  • 驗證分兩層:編輯當下送單列 JSON,提交時才送整份 artifact(見下)
flowchart LR
    C["CellWrapper 編輯"] --> ST["zustand store 更新<br/>畫面立刻反應"]
    ST --> MSG["UPDATE_CELL 訊息"]
    MSG --> WK["Worker"]
    WK --> SQL["SQL UPDATE"]
    WK --> INV["invalidateCounts<br/>搜尋條件下的 totalCount 可能變了"]

BulkInputTable.tsx 的註解把分界講得很準:externalQuery: store.rows IS the page window——所以 absoluteRowIndex 的語意在兩種模式下不同,非 externalQuery 要透過 sortedIndices[pageOffset + row.index] 換算,externalQuery 下 row.index 直接就是答案。

編輯迴圈:單列 revalidate,整包只在提交時送一次

改一格不會重送整份 artifact。usePreviewRawMaterial.ts:357-360 打的是原本那支同步 endpoint,body 是長度 1 的陣列:

const response = await postRawMaterialBulkRevalidate({
  query: { enterpriseId, projectId },
  body: { rawMaterials: [rawMaterial], activityData: [] },
});

revalidate 沒有跟著改成非同步——validate 改成 202 + workflowId 了,revalidate 維持原樣直接回 validateResults,不走 workflow 輪詢。DTO 註解寫明這是刻意的。

觸發條件也不是「每改一格就送」:

  1. cell-blur 才觸發,打字過程不送
  2. 欄位要在 validatableFields 白名單裡
  3. 同一列 300ms debounce——連續改同一列三個欄位只送一次
  4. 停用的列跳過

原本還有第五個條件「只有已經是 error 狀態的列才重驗」,被 revalidateAllEditedRows(D28)關掉了,所以現在每個編輯過的列都會重驗。驗完呼叫 clearDirty('rm', rowNo) 清掉 Worker 裡那列的 dirty 標記——註解說這是唯一清 dirty 的地方,而且只在成功時清。(這解釋了 rm_rows / ad_rows 為什麼要為 dirty 建索引。)

提交時才是整包:

flowchart LR
    EX["db.export 完整 sqlite bytes"] --> GZ["compressGzip"]
    GZ --> POST["POST submit<br/>Content-Encoding gzip"]
    POST --> BE["後端 gunzip 讀 artifact<br/>完整重跑驗證 寫進資料庫"]

兩層驗證的分工

時機送什麼目的
revalidatecell blur + 300ms debounce單列 JSON即時回饋,當下就看到錯
submit按下提交整份 artifact binary最終把關,前端的驗證結果一律不採信

後端在 submit 時完整重跑一次驗證,不管前端說什麼;任何一列有錯就擋掉整批寫入,產一份 errors-only artifact 讓前端 merge 回去修。所以前端的即時驗證純粹是 UX,不是 gate

整份 artifact 的往返只發生在提交那一刻——但一旦提交失敗要重試,就得整份重來(db.export() 的 2.5–3× 峰值、重新 gzip、重新上傳、後端重驗全部列),而且是全新的 workflowId(retry.maximumAttempts: 1,沒有伺服器端續傳)。

三、瀏覽器裡的 SQLite 怎麼運作

sql.js 是把 SQLite 的 C 原始碼用 Emscripten 編成 WebAssembly,跑的是真正的 SQLite engine,query planner、B-tree 都是原版。

export const openDatabase = (SQL, bytes?) => new SQL.Database(bytes);
flowchart TD
    BE["後端產 .sqlite<br/>better-sqlite3"] --> GZ["gzip<br/>~30MB"]
    GZ --> DL["HTTP download<br/>responseType: blob"]
    DL --> AB["arrayBuffer 加 gunzip<br/>ArrayBuffer ~300MB<br/>主執行緒"]
    AB -->|"postMessage transfer<br/>零複製 來源 detached"| W["Worker 收到<br/>new Uint8Array 只是 view"]
    W -->|"複製進 WASM heap"| DB["new SQL.Database bytes<br/>SQLite 在 WASM linear memory"]
    DB --> Q["QUERY_PAGE<br/>只回傳當前 50 列"]

沒有真正的 file handle:瀏覽器不能像 server 那樣 fopen 再只讀需要的 page,sql.js 預設 VFS 是純記憶體的,整份 DB 一定完整存在 WASM heap。所以 1M 列的記憶體成本沒有消失,只是從 JS heap(被 form object 放大過)搬到 WASM heap(原始 SQLite page 格式,緊湊很多)——這才是真正的節省來源

WASM heap = WebAssembly 的線性記憶體:一塊連續 bytes,從 JS 看就是巨大的 ArrayBufferModule.HEAPU8)。C 裡的「位址 0x1000」就是 HEAPU8[0x1000],指標其實只是索引。

JS heapWASM heap
誰管V8 GC 自動回收C 程式碼自己 malloc / free
結構物件圖,有 header、hidden class一整塊連續 bytes
怎麼變小GC 跑過就回收只有 free() 會還,整塊通常不縮回去

所以 worker.ts 在 OPEN 前先 db?.close() 是必要的——沒這行,重複上傳每次疊加一份 300MB,穩定洩漏。

記憶體歸屬:Worker 跟分頁在同一個 renderer process,共用同一份預算。

flowchart TD
    subgraph P["OS process renderer 一個分頁進程"]
        subgraph MT["主執行緒"]
            I1["V8 isolate 1<br/>React DOM zustand"]
        end
        subgraph WT["Worker 執行緒"]
            I2["V8 isolate 2<br/>worker 的 JS"]
            LM["WASM linear memory<br/>SQLite 那 460MB"]
        end
    end

「另外開一塊」這個直覺對了一半:Worker 確實有自己的 V8 isolate——獨立的 heap、獨立的 GC、獨立的 hidden class 表,兩個 isolate 之間不能直接共享物件參照(這正是 postMessage 只能傳可序列化資料或 transferable 的原因)。所以 Worker 的 GC 停頓不會卡住主執行緒的畫面。

isolate 不是 process:兩個 isolate 加上那塊 WASM 記憶體都在同一個 OS process 的位址空間裡。可驗證的三個推論:分頁被 OOM 砍掉時 Worker 一起死/工作管理員的數字以 process 為單位、包含 Worker/開 Worker 不會多拿到記憶體,只繞開執行緒阻塞。

真正在不同 process 的只有 Service Worker 與 cross-origin iframe,這專案兩個都沒用到。

硬限制:WASM linear memory 4GB(wasm32 位址空間,規格層級)/單一 ArrayBuffer Chrome ~2GB/iOS Safari 每 process 數百 MB,超過直接被系統砍掉,連 error event 都沒有

四、記憶體的空間複雜度(線性放大,差別只在常數)

這一節的 V8 / WASM 機制屬於背景知識,不是 MR 本身寫的;帶 (推論) 的才是對這份程式碼的推測。

結論:這次改動沒有把空間複雜度從 O(N) 降下來,降的是常數與「同時存在幾份」。

舊做法新做法
後端產出物JSON 字串 O(N).sqlite O(N)
前端常駐(資料本體)JS heap O(N),被 form object 放大WASM heap O(N),SQLite page 格式
前端渲染 working setO(N)(全陣列排序、切片、column def 重建)O(pageSize)=O(1) relative to N
峰值倍率難以界定OPEN ≈ 2×、EXPORT ≈ 2.5–3×

真正變成 O(1) 的只有第三列。第二列仍然是 O(N),因為 sql.js 的 VFS 是純記憶體的。

「放大」是什麼意思

SQLite 那側,一列就是 page 裡一段連續 bytes,額外開銷只有一個 record header:

flowchart LR
    subgraph SQLiteRow["SQLite 一列 約 460 bytes"]
        H["record header<br/>各欄位型別與長度"] --- V1["值1 bytes"] --- V2["值2 bytes"] --- V3["...值33 bytes"]
    end

同一列進到 JS 變成一張物件圖:

flowchart TD
    ROW["form row 物件<br/>V8 object header<br/>map pointer 加 properties 加 elements"]
    ROW --> S1["字串物件 name<br/>header 加 字元"]
    ROW --> CAT["物件 category"]
    CAT --> L1["字串 label 金屬"]
    CAT --> V1["字串 value metal"]
    ROW --> SUP["物件 supplier"]
    SUP --> L2["字串 label 台灣鋼鐵"]
    SUP --> V2["字串 value sup_001"]
    ROW --> MORE["...另外 30 個欄位"]
    ROW --> UI["rowStatus dirty errors<br/>原始資料沒有的 UI 狀態"]

放大來自五件事疊加:

  1. 每個字串都是一個 heap object — V8 的字串帶 header(map pointer、length、hash),短字串的 header 可能比字元本身還大;含非 Latin-1 字元(中文)會整條升級成 two-byte 表示
  2. 物件本身的固定成本 — map pointer(指向 hidden class)、properties backing store、elements pointer,光是存在就要幾十 bytes
  3. {label, value} 把一個值變成三個東西 — 一個物件加兩個字串
  4. 多出原始資料沒有的欄位rowStatuserrorsdirty;SQLite 那側 errors 在獨立的表
  5. 指標間接性造成的碎片 — 物件散落 heap 各處,實際 RSS 高於理論加總

對照:"台灣" 在 100 萬列裡重複 100 萬次,SQLite 也是老實存 100 萬份 bytes,差距不在資料本身,全在包裝。

常數是多少

bulk-create-common.config.ts 的註解為準:

500k 列 → 解壓後 ~245MB
  1M 列 → 解壓後 ~460MB      → c_sqlite ≈ 460 bytes / row(rm + ad 兩張表合計)
gzip 後 ~30MB vs ~300MB       → 壓縮比約 10×

舊做法那側沒有實測數字,只能從物件圖推:單列 footprint 大致落在數 KB等級,c_form / c_sqlite 保守估 5–10×。(推論)

峰值倍率是什麼

峰值倍率 = 那條路上同一瞬間最多同時存在幾份完整資料,以穩定狀態大小 S 為單位。

OOM 不是發生在穩定狀態,是發生在峰值。 常駐 460MB 聽起來安全,但如果 OPEN 的瞬間需要 920MB,裝置就得撐得住 920MB。

S = 解壓後 artifact 大小(1M 列 ≈ 460MB),C = gzip 後(≈ S/10)。

前端 OPEN

flowchart LR
    subgraph T1["解壓後"]
        A1["ArrayBuffer S"]
    end
    subgraph T2["複製進 WASM 的瞬間 峰值 2S"]
        A2["來源 bytes S<br/>複製期間必須完整有效"]
        B2["WASM heap S"]
    end
    subgraph T3["GC 跑過之後"]
        B3["WASM heap S"]
    end
    T1 --> T2 --> T3

關鍵在 new SQL.Database(bytes)複製不是搬移:_malloc 一塊 WASM 空間再 HEAPU8.set(bytes) 寫進去,複製進行到一半時來源必須完整有效,所以兩份必然並存。複製完成後來源才變成垃圾,而回收時機由 GC 決定——大 buffer 直接進 large object space,要等 major GC 才處理,所以 = null 之後 devtools 數字不會立刻降,這不是洩漏。

峰值 ≈ 2S + C(≈ 2.1S),穩定後降回 S。gzip 那段用 pull-based 分塊,理論上不需要再多一份完整副本;但解壓結果最終要組成單一 ArrayBuffer,組裝瞬間分塊與成品是否並存,程式碼沒明說。(推論)

前端 EXPORT(峰值最高,發生在按下提交之後——最不能失敗的時刻)

flowchart LR
    W["WASM heap S<br/>原本就在 且是資料來源"] --> E["db.export 第二份 S"]
    E --> G["compressGzip 輸出 C"]
    W -.->|"三者同時存在"| G

理論峰值 ≈ 2S + C ≈ 2.1S。session 當時估 2.5–3×,差額大概來自 WASM heap 只增不減加上壓縮的中間 buffer。兩個數字沒對齊,實測前不要當定論。 (推論)

後端 writer(風險最高的一處)

flowchart TD
    M["mapper 完整展開<br/>RmRow 加 AdRow JS 陣列 A"] --> D["memory SQLite DB S"]
    D --> B["db.serialize 的 Buffer S"]
    B --> G["gzip 輸出 C"]
    M -.->|"單一 Node process 內全部並存"| G

峰值 ≈ A + 2S + CA 是 JS 物件陣列,量級接近舊前端那個被放大的常數,整體很可能落在 4–5S——1M 列就是好幾 GB,同步跑完、event loop 全程停住。(推論:A 的量級是從 mapper 結構推的)

倍率是相對值:460MB 的 artifact 峰值 920MB,1GB 的峰值 2GB。

WASM heap 為什麼不會自己縮回去

WebAssembly.Memory 只有 grow() 沒有 shrink()。SQLite 內部 free() 掉的空間會被它自己重複利用,但整塊配額不縮。所以 devtools 看到居高不下是正常的;db?.close() 是唯一釋放手段;JS 的 GC 完全管不到這塊,它只會回收那個指向 WASM memory 的包裝物件。

上限怎麼換算

WASM 4GB 是 wasm32 的 2^32 定址上限(規格層級,非瀏覽器政策)。以 460 bytes/row 反推理論上限在千萬量級——但沒有意義:峰值 2× 先砍半/單一 ArrayBuffer Chrome 約 2GB 先撞牆/iOS Safari 每 process 數百 MB,460MB 已在被系統砍掉的區間。

所以 SQLITE_ROW_CAP = 1_000_000產品決定的,不是從記憶體推導出來的;它在桌機成立、行動裝置不成立。

五、Worker 怎麼「啟動」(沒有註冊這回事)

  • Web Worker 不用註冊new Worker(url) 就直接啟動;要 register() 的是 Service Worker(它得在沒有分頁時也能被喚醒,所以要登記在 origin 層級),這專案沒用到
  • webpack 那段不是註冊,是打包指令module.parser.javascript.worker: ['CorsWorker from @/utils/CorsWorker', '...'] 告訴 webpack「new CorsWorker(new URL(...)) 也算 worker entry」,它才會獨立打包成 chunk 並把 new URL(...) 改寫成帶 hash 的真實網址('...' 是保留預設偵測)
  • runtime 是 module-scoped lazy singletongetWorker() 第一次呼叫才建立,之後全 app 共用——這就是 validate / preview / edit / submit 能共享同一份開著的 DB 的原因
  • 這一行必須維持確切的 AST 形狀:把 URL 抽成變數會讓 webpack 偵測失效,吐出原始 .ts asset,沒有 build error,只有 runtime 才炸
  • CorsWorker 處理 Module Federation 的跨源問題:同源時直接 new Worker(url);跨源時包一層 Blob(self.__webpack_public_path__ = "http://localhost:3002/" + importScripts(...))——blob URL 算同源所以能建 Worker,classic worker 的 importScripts 允許跨源
  • worker 檔案底部用 typeof window === 'undefined' && typeof self !== 'undefined' 守衛掛 handler,讓測試 import 純函式時不會炸
  • 搭配的 CopyWebpackPluginsql-wasm-browser.wasm 複製到 dist/,sql.js 的 browser build 會在 runtime 用 locateFile 抓這個確切檔名

六、後端(cupcake !1777)

SQLite artifact 層rm_rows(33 欄)/ ad_rows(22 欄)/ errorsid, sheet, rowNo, rowId, field, code)/ meta(只放 schemaVersion)四張表,11 個索引。reader 開檔時跑 PRAGMA integrity_check + table/index 白名單 + assertSchemaVersion,逐列重跑長度上限檢查(不信任 writer 已經擋過),SELECT 明確列欄位而非 SELECT *。errors-only artifact 由獨立的 SqliteArtifactErrorsWriter 產,只有 errors 表沒有 meta,reader 用白名單自動辨識並跳過版本檢查。

Temporal:validate / submit / export 是三個獨立 workflow,每個只包一個 activity。

  • artifact 不走 Temporal payload(預設 ~2MB 上限),activity 的輸入輸出只有 fileId / gcsArtifactPath / id / count 等小字串,.sqlite.xlsx 全走 GCS
  • progress 用 Context.current().heartbeat({percent}) 傳出,status endpoint 從 pendingActivities[0].heartbeatDetails 解回
  • 只有 validate 的 45→90 是真的(按已寫入列數線性內插,每 5000 列更新);submit 最長的 DB 寫入那段被 setInterval(30_000) 釘死在 70%,純粹為了餵飽 heartbeatTimeout: '2 minutes'
  • 新開專屬 task queue PCF_BULK_HEAVY(預設併發 3),註解明講就是不讓 1M 這條 head-of-line block 掉既有 50k JSON 流程
  • startToCloseTimeout: '1 hour'retry.maximumAttempts: 1
  • submit 列級驗證失敗是「部分成功但被擋」:errors-only artifact 先上傳 GCS,再丟 BulkSubmitRowValidationFailedError,activity 把 gcsArtifactPath 塞進 ApplicationFailure.details;前端拿到 {status: FAILED, failedRowCount} 後打 download,後端沿 failure/cause chain 解出路徑串回那份 errors-only artifact——這就是前端 MERGE_ERRORS_ARTIFACT 的來源。submit 成功時 failedRowCount 寫死 0,全有全無

API 契約:五個 endpoint 全部對得上。submit / export 是雙模 endpointapplication/octet-stream 走新路徑,application/json 仍走舊 smart-bulk-create 同步路徑),這解釋了前端為何保留 submitLegacy.ts

main.ts 有一段只針對 bulk_create 的 middleware:Content-Type 是 octet-stream 時主動拆掉進來的 Content-Encoding: gzip header,不讓 Express body-parser 自動解壓——註解寫明原因是自動解壓會把 65MB 變 245MB,server 還要再壓回去傳 GCS,白做一輪。controller 拿到的是還壓著的 raw Buffer,直接丟 GCS,解壓延後到 activity。limitSQLITE_ARTIFACT_MAX_FILE_SIZE_BYTES(預設 500MB)。

授權模型:download 沒有 project/enterprise guard,enterpriseId / projectId 只是形式一致而收,不用於授權;實際存取控制全靠 Temporal workflow 的 memo.userId + timingSafeEqual,fail-closed 丟 62097/403。enterpriseId 在 service 層是資料範圍過濾,不是成員身分檢查。

新錯誤碼:62097(ownership 不符 / 403)、62099(artifact 完整性或欄位長度超限 / 400)、62100(meta.schemaVersion 不是 1.1.0 / 400)、40400(artifact not found,沿用既有數字碼但覆寫 getStatus() 成 404;GCS 物件有 7 天生命週期,過期也走這條)。

七、風險清單

🔴 最嚴重:GET /bulk/workflow/status 的 ownership guard 是無條件的 assertOwnership 在所有分支之前呼叫,1M 前綴判斷在它之後、只決定回應欄位。而全 repo 只有 bulk-create-raw-material-workflow.service.ts 三處寫 memo: { userId, projectId };其他所有 workflow 走 tasks/adapters/temporal.adapter.ts,memo 只放 projectId / enterpriseId從來沒有 userId。這個 endpoint 是共用的(bulk-delete、org bulk-create、DC bulk input、bulk-edit、pipeline 都在打),deploy 後這些輪詢一律拿到 403。common.service.spec.ts 沒抓到,是因為測試連 legacy workflow id 都手動 stub 了 memo: { userId: TEST_USER_ID },跟實際產出不一致。

🔴 部署順序:後端不能先於前端部署 export / submit 都按 Content-Type 分流保留 legacy 行為,validate 沒有這個分支——被就地改成無條件 202 回 {workflowId},舊前端打過去只會拿到 workflowId、沒有任何驗證結果。兩個 MR 必須同時上;前端的 *Legacy 檔案是 code 層面回退用的,不是 runtime feature flag

🟠 前端記憶體完全沒有防線 沒有 artifact 大小檢查、沒有 OOM 處理(RangeError 只會走 onerror → 顯示一句原始英文訊息)、iOS 分頁被砍時連 error event 都沒有。上傳端只有 maxSize={50MB}(Excel),但 50MB xlsx 解壓成 artifact 可以是好幾百 MB——入口檔案大小無法推導 artifact 記憶體用量

🟠 後端 writer 全程 in-memory + 單一超大 transaction new SqliteArtifactWriter() 沒傳 filePath:memory:,最後 db.serialize() 再吐一份完整 Buffer,上面還疊著已完整展開的 RmRow[] / AdRow[]writeRmRows / writeAdRows 把整個陣列包在一個 db.transaction() 裡跑同步 for loop,這段時間 Node event loop 是停的。(Temporal 那層有以 5000 列為一批寫入的路徑,兩份 agent 報告在這點描述不一致,見矛盾點。)

🟠 CompressionStream 沒有 fallback 也沒有 feature detection gzip.ts 檔頭承認沒有 fallback,理由是「supported-browser matrix 一定有」,但專案裡沒有 browserslist / babel targets 把這個約定固定下來。不支援的瀏覽器會當場丟 ReferenceError。詳見第九節。

🟠 測試涵蓋度 叫做 1M 的功能,最大 fixture:sqlite writer spec 200 列、payload-store 5001 列、pipeline 1000 列。沒有任何測試跑真實 Temporal worker(@temporalio/* 整個被 mock,沒有 TestWorkflowEnvironment),e2e 驗的是 HTTP 契約而非資料正確性。Worker + WASM + Streams API 在 jsdom 下也測不到真實行為。

🟡 單列 revalidate 目前送不出 activityData revalidateRow 的註解:activityData for this RM is not yet sourced from the Worker here — the payload's activityData is empty pending that wiring. 需要看該原料底下活動數據才判斷得出來的規則,這條路驗不到,要等 submit 的完整重驗才會浮現。標記為 Phase 4 待處理。

🟡 其他脆弱點 module-scoped 全域狀態(sharedWorker / pendingRequests / isDbOpenGlobal / dbLifecycleListeners)生命週期不跟 React 綁,useSqliteWorkerQuery 裡「hook 晚掛載要 replay open 狀態」的 workaround 就是直接後果,MFE 反覆掛載風險更高/CorsWorker 依賴 webpack 內部 AST 偵測,升級容易靜默壞掉/多處 render-time ref 寫入配 eslint-disable react-hooks/refs*Legacy 雙軌長期是雙倍維護面,應該要有明確刪除時點/buildSearchClause 對 33 個欄位各做一次 LIKE '%…%',前置萬用字元用不到索引,1M 列就是 33 次全表掃描。

🟢 build 沒問題 better-sqlite3@^11.8.1 已加進 Dockerfile:66 既有的 npm rebuild canvas 那行,跑在 builder stage;Node 固定 node:20-slim 兩 stage 一致,ABI 對得上。但 runtime image 完全沒有 build toolchain,那行 rebuild 若被誤刪會在啟動時直接 crash。沒有 DB migration,所有新 config 都有安全預設值。

八、IndexedDB / OPFS 這條路

直接用 IndexedDB 取代 SQLite 不會是好選擇——它是 key-value store 加預先宣告的索引,任意欄位排序要 33 個索引、LIKE '%x%' 完全沒有、COUNT(*) WHERE 要自己 cursor 數、沒有 join、沒有運算式索引,等於要在 JS 裡重寫一個查詢引擎;而且後端已經產出 .sqlite,改用 IndexedDB 還得先把 100 萬列一筆筆寫進去。

真正的方案是換掉 SQLite 的 VFS

VFS儲存在哪記憶體
sql.js 現在用的WASM linear memory整個 DB 常駐,受 4GB 與分頁配額限制
absurd-sqlIndexedDB只有 page cache 常駐
官方 sqlite-wasm 的 OPFS VFSOrigin Private File System只有 page cache 常駐

OPFS sync access handle 是什麼,以及 SQLite 為什麼非它不可

OPFS(Origin Private File System) 是給網頁用的私有檔案系統,每個 origin 一份,使用者看不到、也不會出現在下載資料夾。它跟 IndexedDB 的根本差別是它真的是檔案——可以 seek 到任意位置、讀某個區間、寫某個區間。

const root = await navigator.storage.getDirectory();
const file = await root.getFileHandle('db.sqlite', { create: true });
const handle = await file.createSyncAccessHandle();
 
handle.read(buffer, { at: 4096 });    // 同步,沒有 await
handle.write(buffer, { at: 8192 });   // 同步

那兩行沒有 await。這是整個瀏覽器儲存生態裡少數的同步 I/O,也是它存在的唯一理由。代價是只能在 Worker 裡用——主執行緒禁止。

SQLite 的核心是同步的,VFS 介面長這樣:

int xRead(sqlite3_file*, void* buf, int amt, sqlite3_int64 offset);

「從 offset 讀 amt 個 byte 到 buf」——回傳時資料就必須在 buf 裡了,沒有 callback、沒有 promise 的位置。所有非同步儲存 API(IndexedDB、fetch、File API)都接不上。這就是為什麼過去在瀏覽器跑 SQLite 只能整個載進記憶體——因為記憶體存取是同步的。

有了它,SQLite 才能只讀用得到的 page,300MB 的 DB 常駐可能只剩幾 MB 的 page cache。

這個 MR 沒這樣做的阻力

要換成 @sqlite.org/sqlite-wasm(worker 層重寫)/官方 OPFS VFS 主要模式依賴 SharedArrayBuffer + Atomics.wait,需要 COOP/COEP header,在 Module Federation 下會連帶影響 host 載入的所有其他 remote/Worker 從 blob URL 建立,blob 繼承的是建立它的 document 的 origin(host 的 :3000,不是 souffle 的 :3002),OPFS 會落在 host origin 底下/資料實際寫到使用者硬碟可能有法遵問題/OPFS sync access handle 是 Safari 16.4(2023)才有。

結論:以「先讓功能能動」來說目前選擇合理,但有天花板——桌機撐得住 460MB,iPad 撐不住。若要支援行動裝置或列數再往上長,OPFS 是唯一出路,而且越晚換成本越高(現在 worker 協定還乾淨,就七個訊息類型)。

九、瀏覽器相依:卡點不在 WASM

WASM 本身不是問題——2017 年就全面落地(Chrome 57 / Firefox 52 / Safari 11 / Edge 16 同一年)。真正不支援的只有 IE11,而它 2022 年已停止支援。

能力ChromeSafariFirefox用在哪
WebAssembly57(2017)11(2017)52(2017)sql.js
Web Worker遠古遠古遠古
CompressionStream80(2020)16.4(2023-03)113(2023-05)gzip.ts
OPFS sync handle10816.4111未使用,未來換 VFS 會需要

卡住的是 CompressionStream / DecompressionStream,不是 WASM。

瀏覽器內建的 gzip 壓縮器,Streams API 的一員,實作在 C++ 層(zlib),比 pako 這類 JS 函式庫快很多且不佔 bundle:

readableStream
  .pipeThrough(new CompressionStream('gzip'))
  .pipeThrough(new DecompressionStream('gzip'));

這個 MR 怎麼用它gzip.tschunkedSource 把已經在記憶體裡的 buffer 切成 4MB 一塊餵進去。為什麼要自己切塊——為了進度回報:ReadableStreampull() 只有在下游真的消化完前一塊時才會被呼叫,所以 onProgress 反映的是實際壓縮進度,而不是「已經丟進 buffer 的量」。

解壓那邊多一層 magic-byte 檢查0x1f 0x8b):如果 server 回應帶了 Content-Encoding: gzip,瀏覽器會自己先解壓,這時再跑一次 DecompressionStream 會丟 incorrect header check

風險排序:這四個能力裡先擋住使用者的不會是 WASM 也不會是 CompressionStream,而是記憶體。Safari 16.4 已是三年前的版本;但 iPad 的 process 限制是現在式,失敗方式也更難處理。

反覆出現的關鍵字

  1. artifact 當傳輸格式.sqlite 二進位本身就是 HTTP body,貫穿前後端與 GCS,是整個設計的軸心
  2. WASM heap / linear memory — 「為什麼省記憶體」與「為什麼有天花板」的同一個答案
  3. 峰值 2×–3× — OPEN、EXPORT、後端 writer 三處,是最一致的風險形狀
  4. transfer 而非 clonepostMessage 的 transfer、detached ArrayBuffer
  5. db?.close() / explicit free — WASM heap 不會自己縮回去,是 GC 管不到的邊界
  6. workflowId + 輪詢 — validate/submit/export 全部 202 + workflowId
  7. memo.userId + fail-closed — ownership guard 的實作方式,也是最大風險的來源
  8. legacy 雙軌 / Content-Type 分流 — 前端 *Legacy、後端雙模 endpoint
  9. 確切的 AST 形狀(webpack) — 靜默失敗的依賴
  10. 「不 head-of-line block 既有流程」 — task queue 隔離、externalQuery optional prop、雙模 endpoint

矛盾點

session 內的自我訂正(結論以後者為準)

  1. ARTIFACT_SCHEMA_VERSION 是不是死常數 — 訂正後:SCHEMA_VERSION = '1.1.0'(schema.ts)才是唯一生效的,ARTIFACT_SCHEMA_VERSION = '1'(config.ts)存在但沒被 sqlite 層引用。兩者並存且值不同,是容易誤用的陷阱。
  2. 記憶體「與列數脫鉤」 — 早期評估把記憶體列在「好處」,後來承認只講了常駐、沒講峰值
  3. rowId 為 null 會不會破壞前端 merge — 查證後不成立:前端 group key 是 ${sheet}:${rowNo}rowId 不參與比對。
  4. 「編輯要重送整包」 — 錯的。編輯走單列 JSON 的 revalidate,整份 artifact 只在提交時往返一次;dirty 索引就是給這條迴圈用的。

兩份 agent 報告互相打架(尚未收斂)

  1. 後端 writer 是否分批寫入 — SQLite artifact 層那份說「單一未分塊 transaction」,Temporal 層那份說「以 5000 列為一批(SQLITE_ARTIFACT_WRITE_BATCH_SIZE)」。很可能是前者只讀 writer 檔案、沒看到呼叫端的分批迴圈。要自己回去確認。(推論)
  2. CUSTOM_FACTOR_CHUNK_SIZE = 5000 是否被引用 — 可能與 SQLITE_ARTIFACT_WRITE_BATCH_SIZE = 5000 被混為一談。(推論)

程式碼 / 註解 / 文件之間的不一致

  1. whitelist 檢查的強度 — 檔頭註解宣稱比對 sqlite_mastersql 文字,但 matchesWhitelist 只比 type + name,同名表被改欄位定義驗不出來。實際防護靠明確列欄位的 SELECT 加逐列長度檢查。
  2. downloadLink 是否被移除 — 前端說「被移除」,後端 DTO 裡沒有移除,只是 1M 這族從不填它。createdRawMaterialCount / createdActivityDataCount 同理永遠 undefined
  3. download 的「三種變體」 — DTO 註解說可用 Content-Type / Content-Encoding 分辨三種,後端實際只有兩種組合(validate 與 submit errors-only 完全相同)。
  4. ARTIFACT_MAX_ROW_LIMIT 沒被強制 — 實際擋列數的是 Temporal 那層的 SQLITE_ROW_CAP
  5. 前端 MR 自陳「validate artifact errors 表為空、rowStatus 全 OK」 — 錯誤顯示這條主要路徑端到端沒驗證過。
  6. progress 的語意 — COMPLETED 時 progress: 100 是硬寫的;submit 的 70% 不是真進度。前端 MonotonicSegmentedProgress 某種程度上是在補這個洞。(推論)

抽出的原子筆記