軟刪除、回收桶與版本歷史
自訂資料表把「刪除後可復原」與「回到過去某一版」分成兩套功能。回收桶改變物件是否為 live;版本還原則把舊快照寫成一個新版本。兩者都保留稽核軌跡,也都必須重新通過目前的權限與規則。第三條流程——永久 purge——是這兩者的反面:它不可逆地銷毀一張表或整個 tag 系統(連同歷史),由下文的 preview 加 ticket 同意閘把關。
表的回收桶
管理者刪除表時,DELETE .../tables/{table_id} 會把整張表移入 trash,而不是立即清掉 schema、資料列、設定與 history。管理 UI 可用:
GET .../tables/trash?skip=0&limit=20
POST .../tables/{table_id}/restore列出 trash 是範圍層級操作;restore 則用一張已刪除表的 ID。表在 active migration lock 中不能刪除。不可逆的資料清除是一條刻意更重的獨立流程——下面的兩步驟 purge——因此一般產品 UI 應把「移到回收桶」與「永久清除」用完全不同的文案與確認層級呈現。
永久 purge:preview、ticket、execute
自 2026-07-28 版起,永久刪除是租戶操作,不再只屬於 root 操作員。同意閘不是保留天數,而是兩步驟流程:
POST .../tables/{table_id}/purge/preview → 爆炸半徑 + 一次性 ticket
POST .../tables/{table_id}/purge?ticket=… → 燒掉 ticket、銷毀資料表preview 會算出 purge 將碰到的一切——現存資料列數、外部活表會被剝除的連結欄位(以顯示名稱列出)與被剝除的 rules 數量、saved views、IaC state rows、將被刪除或變成殭屍的 commands——blockers 為空時發出 ticket:綁定呼叫者、效期 15 分鐘、只夠一次執行嘗試。把這份回應原樣渲染成確認畫面;preview 與實際 cascade 共用同一套實作,畫面不可能與執行結果漂移。
execute 無論成功或被拒都會燒掉 ticket。preview 之後只要結構有變——表被還原或改名、command 增減、tag 掛載變動——回應就是 409、error: "purge_preview_stale":重新 preview、重新核准你現在看到的內容。指紋刻意只鎖結構;preview 與 execute 之間寫入的資料列會跟著表一起銷毀,因為活表若連資料一起鎖,每次 execute 都必然 stale。
UI 上要緊的細節:
- **活表直接 purge。**不需要先進垃圾桶;ticket 流程本身就是同意。垃圾桶裡的表也能 purge——這會釋放它占用的名字,光待在垃圾桶則會一直占著。
- 誰能做:單表 lane 要求資料表 moderator(活表與垃圾桶中的表都解析)。tag lane——
POST .../table-tags/{tag_id}/purge/preview與.../purge——一次銷毀所有成員表、該 tag 的全部 commands(名字立即釋放)、其 IaC state rows 與 tag 本身,因此要求 scope-manager 權威,不是一般 tag 刪除那道門檻。 - **掛多個 tag 的成員會擋下。**成員表同時掛著別的 tag 就是
table_in_other_tagsblocker:不發 ticket,execute 時還會再驗一次——A 系統的 purge 永遠不能連坐 B 系統的成員。另一種 blocker 是command_referenced_by_trigger:tag 的 command 仍被系統之外某張表的觸發器呼叫時擋下;將被 purge 的成員表自己的引用不會擋。 - **部分失敗是持久事實。**tag lane 裡每張成員表的 cascade 獨立 commit。中途失敗時,
purge_partial_failure的內容會精確列出哪些表已經消失——把那份清單當事實,對剩餘系統重新 preview。 - root 操作員 purge(
POST /root/custom-tables/{table_id}/purge,含force)原樣保留,仍是操作員內部工具。
資料列的 trash/restore
DELETE .../records/{record_id} 會把資料列標為 deleted;一般 list/search/get 不再回傳它。回應中的 incoming_links_removed 告訴你刪除時移除了多少反向連結。
有 edit 權限的呼叫者可檢查與還原自己在該權限切片中看得到的已刪除列:
GET .../records/deleted?skip=0&limit=20
GET .../records/deleted/numOfData
POST .../records/{record_id}/restoreRestore 不是把舊 JSON 不經檢查塞回去。伺服器會以目前 schema重新驗證;欄位已移除、型別改變或 required 條件不再成立時可能回 409 conflicts。成功後 is_deleted 變回 false、排序位置重新建立,且 version 再加一,而不是回退版本號。刪除時解除的 link 不應假設會自動重建。
重新建立的排序位置常常不是這一列原本的位置。刪除資料列時會把 sort_order 設成 NULL,並把舊值收進 deleted_sort_order;restore 只有在沒有任何資料列正佔著那個槽位時才會拿回原位,否則就取 (全表每一列的 MAX(sort_order)) + 1。因為刪除已經把那個整數釋放出去,之後的 insert 完全可能合法地拿走它——所以除非你重新讀一次,否則就把還原後的位置當成「被接在最後面」。這個 fallback 的 MAX 刻意也涵蓋已刪除的資料列:在槽位還沒被設成 NULL 的年代被 soft delete 的舊資料列仍然佔著一個,還原時落在它上面,才是 restore 不會撞上 uq_table_sort_order unique key 的原因。Constraint 與 insert 端的槽位運算見 sort_order 槽位。
權限與審批仍然生效
Trash 列表需要 edit ACL;restore 也重新檢查 edit row policy。若
require_approval規則涵蓋 restore,呼叫會先建立 staged change,live row 仍維持 deleted,直到核准。
版本是只增不減的時間線
每次 create、update、delete、restore 或 revert 都會留下歷史。可從兩個角度讀取:
GET .../tables/{table_id}/history:整表 audit log,包含資料列與 schema 變更,可依change_type/record_id篩選。GET .../records/{record_id}/history:單列版本,由新到舊分頁。GET .../history/{version}:一版完整 stored-data snapshot。GET .../history/{version}/diff?compare_to={other_version}:兩版欄位級 diff。
History 的 data 與 diff 使用內部 col_<hex> 鍵,讓改名後的稽核仍能對到同一欄。UI 要拿目前表的 column_mapping 顯示欄名;若欄位已刪除,保留內部鍵作為誠實的歷史識別,不要猜一個新名稱。
{
"from_version": 3,
"to_version": 1,
"diff": {
"col_a1111111_1111_4111_8111_111111111111": {
"old": "已出貨",
"new": "草稿"
}
}
}Version restore 會建立新版
POST .../records/{record_id}/history/{version}/restore 不會刪掉目標版本之後的歷史,也不會把版本計數器倒轉。它把指定版的 stored scalar data 套用到目前列,新增一筆 change_type: "revert" 的 history entry:
{
"restored_from_version": 1,
"new_version": 4,
"links_not_restored": true,
"record": {
"version": 4,
"data": { "狀態": "草稿" }
}
}Link 狀態不屬於 scalar snapshot,因此不會跟著歷史版本回復;請檢查 links_not_restored 並讓使用者重新確認連結。伺服器也會對照目前 schema,若舊版包含已移除/不相容欄位,以 409 conflicts 拒絕。
Version restore 本身是一種寫入,會套用 edit ACL、write rules、migration lock 與 require_approval。命中審批時回 approval_required,舊版尚未進入 live row;核准後才產生新的 revert 版本。流程見審批總覽。
UI 建議
把四個動作分開命名:
- 從回收桶復原:讓 deleted 物件重新變 live。
- 檢視版本/比較差異:唯讀稽核,不改資料。
- 還原為此版本:新增一版,可能需要審批,link 不還原。
- 永久 purge:兩步驟 preview → ticket → execute;把 preview 的爆炸半徑原樣渲染成確認畫面,用你最重的確認文案——之後沒有任何東西可以復原。