Skip to Content
核心概念軟刪除與歷史版本

軟刪除、回收桶與版本歷史

自訂資料表把「刪除後可復原」與「回到過去某一版」分成兩套功能。回收桶改變物件是否為 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。移入 trash 不取得 migration lock,也不破壞任何東西——只會切換表的刪除狀態,並取消它待審的 staged changes——但若另一張表有現行資料列透過 same_dimension_reference 規則依賴它,會以 409 same_dimension_reference_violation 拒絕(restore 也做同樣檢查;此外,若這張表的 require_approval 範本已失效,或它的某個觸發器會寫入受審批管控或無法解析的目標,restore 也會以 409 拒絕——approval_template_missing、approval_trigger_conflict、approval_trigger_target_scope_unresolved,見 tables.restore)。不可逆的資料清除是一條刻意更重的獨立流程——下面的兩步驟 purge——因此一般產品 UI 應把「移到回收桶」與「永久清除」用完全不同的文案與確認層級呈現。

其他資料表中指向垃圾桶資料表的東西都不會被移除:它們的連結欄位、連結列與 lookup 都會保留,只有在這張表被 purge 時才會改寫。它待在垃圾桶期間,透過連結讀進它的 lookup 不會顯示任何值;以它為 defaults-table fallback 的 lookup 只提供主要那一段;沿連結延伸到它的 link_target 列權限 leaf 一律視為不通過(fail closed)。

永久 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(單表 purge 不刪任何 command;只有下面的 tag lane 會刪)——blockers 為空時發出 ticket:綁定呼叫者、效期 15 分鐘、只夠一次執行嘗試。把這份回應原樣渲染成確認畫面;preview 與 cascade 使用同一套相依探索,所以畫面與執行結果一致,只有兩個缺口:其他表中以被 purge 表為 defaults-table fallback 的 lookup 欄位不會被列出(execute 會默默移除那個 fallback);本身也在垃圾桶中的相依表不會被列出,但 execute 同樣會剝除它的欄位與 rules。

Execute 在 atomic take 時就消耗 ticket,早於執行者/標的/結構變動檢查,被拒也一樣。更早的路由授權、標的查找或 tag preflight 可能在消耗前拒絕;但 tag route 的 purge_blocked 也可能包裝已消耗 ticket 的 missing/stale 失敗。不要由錯誤名稱推斷可重用:被拒後請重新 preview。結構變動(資料表定義/設定、受影響表/Command/tag/view/IaC state 或 blocker)需要重新同意。Runtime 資料列/歷史/run 計數刻意不釘住;預覽後才寫入的資料列仍可能被已同意的 purge 銷毀。保留期限與證據契約另加受保護銷毀檢查與型別化資源計數;保留的 blob 與 Command 執行證據不是銷毀標的。

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_tags blocker:不發 ticket,execute 時還會再驗一次——A 系統的 purge 永遠不能連坐 B 系統的成員。tag 的 command 仍被系統之外某張表的觸發器呼叫時,是 command_referenced_by_trigger blocker;將被 purge 的成員表自己的引用不會擋。兩條 lane 還可能回報 cross_scope_dependency(相依關係跨越 scope 邊界)、unique_tuple_link_dependency(存活表的 unique_tuple 規則用到 purge 會剝除的連結欄位)、same_dimension_reference_dependency(有 same_dimension_reference 規則以將被銷毀的表為參照目標或維度表)與 date_interval_exclusion_dependency(存活、已在垃圾桶或同時被 purge 的資料表上的 date_interval_exclusion 規則,把 purge 會剝除的連結欄位當作分割);請先移除被點名的規則。
  • **部分失敗是持久事實。**tag lane 裡每張成員表的 cascade 獨立 commit。中途失敗時,purge_partial_failure 的內容會精確列出哪些表已經消失——把那份清單當事實,對剩餘系統重新 preview。
  • **存活表會記錄改寫。**cascade 改寫到的每一張存活表,都會以連續的結構版本號寫入歷程(record_id: null、changed_by: null):每剝除一個連結或計算欄位寫一筆 schema_remove_column,每個 fallback 指向被 purge 表的 lookup 寫一筆 schema_update_column,相依規則被剝除時再寫一筆 schema_rules_update。

資料列的 trash/restore

DELETE .../records/{record_id} 會把資料列標為 deleted;一般 list/search/get 不再回傳它,其他地方也不再算它:連結欄位與 expand_links 會略過它,其他資料列的 rollup 與 lookup 會忽略它,no_overlap、date_interval_exclusion、exists/not_exists 與 count_limit 規則檢查的資料列集合也不含它。回應中的 incoming_links_removed 是刪除當下指向這筆資料列的連結列數量。軟刪除不會移除任何連結列;資料列待在 trash 期間,這些引用只是不再顯示。

有 edit 權限的呼叫者可檢查與還原自己在該權限切片中看得到的已刪除列:

GET .../records/deleted?skip=0&limit=20 GET .../records/deleted/numOfData POST .../records/{record_id}/restore

Restore 不是把舊 JSON 不經檢查塞回去。伺服器會以目前 schema重新驗證:型別改變、select 選項被移除、現在必填的欄位存著明確的 null,或 decimal 值不符合欄位目前的精度或正負號限制,都會回 409 conflicts(已移除的欄位不會造成衝突——它們的鍵值在 trash 中的資料列也已被移除)。成功後 is_deleted 變回 false、排序位置重新建立,且 version 再加一,而不是回退版本號。這一列雙向的連結列都被保留下來,所以還原後連結會再次顯示——除非資料列被隱藏期間,某筆來源資料列的連結欄位以 set 改寫過,那樣會刪除該連結列。

重新建立的排序位置常常不是這一列原本的位置。刪除資料列時會把 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,直到核准。在 write_policy 為 commands_only 的資料表上,還原資料列會以 403 governed_context_required 被拒絕(沒有任何 Command 會執行 restore);還原或 purge 整張資料表則屬於例外。

版本是只增不減的時間線

每次 create、update、delete、restore 或 revert 都會留下歷史。可從兩個角度讀取:

  • GET .../tables/{table_id}/history:整表 audit log,包含資料列與 schema 變更,可依 change_type/record_id 篩選,依 created_at、version、再依歷程 id 排序(由新到舊;offset 分頁穩定)。觸發器設定的變更(schema_triggers_update)只列給資料表版主——對其他人會從頁面與 total 中剔除——而且即使是版主,其中的密鑰欄位也顯示為 "__SECRET__"。
  • 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 顯示欄名。當已授權、未受欄位限制的 history 讀者取得已刪除欄位時,應保留內部鍵作為誠實的歷史識別,不要猜一個新名稱。

已合併 #1987(來源 b1e5a13d0e209b41cca460fbe6ba0dc7c7b301a8),依目前 column ACL 與 schema ceiling 過濾 history。受限 principal,包括欄位 allowlist 或 Agent ceiling,不會取得 live schema 外已移除的欄位;未受欄位限制的讀者仍可能取得已移除的 stored field。Snapshot 與 diff 不揭露目前 hidden 值或其 computed provenance;diff 檢查兩個版本的來源證據。缺少 predecessor evidence 時,依賴它的 computed field 會 fail closed。此投影不修改儲存的 history,前端不得從舊 schema 補回被省略的欄位。As-of 讀取則仍只限目前可見欄位。這是 merged-source 文件,不是 staging/production 或出勤驗收聲明。

歷程沒有保存期限。資料列在垃圾桶中時,其歷程、版本與差異仍可讀取;資料表在垃圾桶中時,其歷程回 404,直到資料表被還原;只有永久 purge 會刪除歷程。

結構變更同樣會留下歷程。整張表層級的列(record_id: null)記錄每次欄位、規則或觸發器的變更——data 是變更後的 schema、規則清單或觸發器清單(purge 存活表的列只帶受影響的那個欄位,被剝除欄位的那一列 diff 為 null),diff 是像 {"action": "add_column", …} 這樣的動作物件——並自成一條版本序列,每筆比該表上一筆結構列大 1。新增、移除或改變欄位型別時,也會在每筆受影響資料列的歷程寫入一筆 schema_add_column/schema_remove_column/schema_type_change 版本,並讓該列的 version 加一,所以資料列的版本號並不都是連續的資料編輯;這類列的 diff 可能是動作物件、changed_by 可能是 null(新增或改型別時會略過垃圾桶中的資料列,但移除欄位時連它們一起剝除)。

{ "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。回應如下(節錄——record 是完整的資料列物件):

{ "message": "Record restored to version 1", "restored_from_version": 1, "new_version": 4, "links_not_restored": true, "record": { "version": 4, "data": { "狀態": "草稿" } } }

Link 狀態不屬於 scalar snapshot,因此不會跟著歷史版本回復;請檢查 links_not_restored 並讓使用者重新確認連結。伺服器也會把要寫入的資料對照資料表目前的 schema,不相容時回 409 並附 conflicts 清單,不做任何變更:removed_column(該版本含有之後被刪除的欄位,因此無法還原)、missing_required_field(之後新增了沒有預設值的必填欄位)、type_mismatch(儲存的值已不被改型後的欄位型別接受——string→text 或 integer→float 可以正常還原——或 decimal 值已不符合欄位的精度或正負號,或必填的 decimal 欄位存著明確的 null;符合的 decimal 會依目前的 scale 重新序列化),以及只有前述都沒有時才檢查的 invalid_value:目前欄位規則不接受的值,例如已停用的 select 選項,或現在必填的非 decimal 欄位存著明確的 null(field 為 null,由 message 指出欄位)。

Version restore 本身是一種寫入——在 commands_only 資料表上會以 403 governed_context_required 被拒絕——會套用 edit ACL(filtered 編輯者還原後的資料列也必須仍在自己的編輯篩選內)、SCP channel 寫入 floor、write rules、migration lock 與 require_approval。對你隱藏的欄位保留目前的值,只有你看得到的欄位會被還原。審批閘把版本還原當成 update 比對:規則的 events 必須包含 update,只要目前資料列或還原後的資料任一符合 when 就算命中。命中時回 approval_required,舊版尚未進入 live row;核准時寫入的正是請求當下檢查過的那一列(並會在當下依目前的 schema 再驗證一次),之後才產生新的 revert 版本。流程見審批總覽。

UI 建議

把四個動作分開命名:

  • 從回收桶復原:讓 deleted 物件重新變 live。
  • 檢視版本/比較差異:唯讀稽核,不改資料。
  • 還原為此版本:新增一版,可能需要審批,link 不還原。
  • 永久 purge:兩步驟 preview → ticket → execute;把 preview 的爆炸半徑原樣渲染成確認畫面,用你最重的確認文案——之後沒有任何東西可以復原。

完整回應與錯誤見回收桶參考、歷史參考與資料表參考;逐步 UI 流程見歷史還原指南。

單筆讀取與時間點查詢

單筆 record/hydrated/incoming-links 讀取,找不到與被目前讀取政策隱藏皆使用 404 {"detail":"Record not found"};這不統一 trash-restore/bulk 的 not-found 措辭,也不取代資料表或範圍權限錯誤。As-of 讀取 同樣遵守目前授權,並拒絕缺少已擷取 link 狀態的舊版本。

Last updated on