自訂資料表觸發器
Trigger 在資料變更提交成功之後建立非同步 run,再依序執行 actions。它適合通知、呼叫外部服務及衍生寫入,不是 validation:action 失敗不會回滾已提交的來源資料,也不能用來否決寫入;需要否決時請使用規則。
定義一個 trigger
{
"name": "新訂單通知與同步",
"on": "created",
"when": [
{ "column": "狀態", "op": "eq", "value": "已送出" }
],
"actions": [
{
"type": "notify",
"chatroom_id": "11111111-1111-4111-8111-111111111111",
"message": "新訂單:$row.訂單編號"
},
{
"type": "api_call",
"method": "POST",
"url": "https://integrator.example/orders/$row.訂單編號",
"body": { "record_id": "$row.訂單編號", "amount": "$row.金額" }
}
]
}on 可為 created、updated、deleted、restored 或 schedule。when 最多 5 個 AND predicates(when exceeds max 5 predicates)。每個 column 都必須能以顯示名稱或內部鍵解析(when column '<column>' not found in '<table>'),整個字串值或最上層 list 元素精確等於 $me、$today 等 row policy token 時會被拒絕(區分大小寫,不去除前後空白);token 若只是較長字串的一部分,則是字面值,因為 trigger 評估時沒有操作中的使用者。儲存時還會檢查什麼,取決於欄位:json 欄位只接受 is_null、is_not_null、eq 與 neq(深度比較,可用物件與清單);principal 欄位(user、social_client、principal)只接受 is_null、is_not_null、eq、neq 與 in,值必須是非空的 id 字串;其他欄位只做結構檢查——op 必須是 eq、neq、gt、gte、lt、lte、in、contains、is_null、is_not_null 之一,in 必須是最多 100 個純量的非空清單——不檢查值的型別。所以 decimal 欄位的值必須是像 "12.50" 這樣的純十進位字串:JSON 數字可以存檔,但之後永遠不會命中。觸發時比較的是原始值:principal 欄位請寫帶標記的儲存格值(user:<uuid>),因為沒有標記的 id 在 eq/in 下永遠不會命中,在 neq 下則會命中每個非空儲存格(materialize_slots 的 source.filter 也一樣)。空白儲存格不會符合任何值運算子,neq 也一樣。updated 還可用 transition: { "column", "from"?, "to"? } 篩選前後狀態。每張表最多 50 個 triggers,每個 trigger 必須有 1 至 5 個 actions。
和 rules 相同,triggers.set 是 full-replace PUT。先 triggers.get(僅限表格 moderator 讀取,見下文)、保留既有 trg_<hex> IDs、修改完整清單再送出。但只保留 trigger id 還不夠:每個 action 都會回傳自己的穩定 id(未自行指定時為 act_<12 hex>),這個也必須原樣送回。 沒有帶 id 的 action 會得到新產生的 id。若某個 trigger 的所有 action id 都換掉,failed 或 pending run 重試時會退回依位置續跑;如果同時插入或重排了 action,receipt 就會對到錯的 action。若只有部分 id 換掉,那些 action 會失去 receipt,重試時再執行一次。原樣送回 id,也是讓唯寫機密綁定到正確 action 的前提。
這個 PUT 也會維護相依 commands 的凍結 dependency contract。伺服器不分 DSL version,會重新編譯所有在編輯前 stored dependency contract 仍 valid 的相依 commands,並在同一個 transaction 中提交 trigger 集合、schema history 與刷新後的 command definitions。若 command 的 stored contract 已因其他原因 stale,則維持 stale。候選 action 若加入新的 target table,仍必須符合每個選中 command 的 tag closure;只要有 command 無法刷新,REST 就回傳 409,且 detail.error: "command_dependency_refresh_failed",並回滾 trigger 集合、history 與所有刷新後的 definitions。成功 body 回傳 table_id、遮罩後的 triggers 與其 secrets slot 清單,不會列出刷新後的 command IDs 或 digests。
IaC trigger 行使用相同的刷新規則,並把 trigger 的 IaC state 一併納入原子 bundle。失敗會回滾該行的 bundle,而整次 apply 仍維持 per-line best effort。
誰能讀取 trigger 設定
Trigger 設定(含外呼的 URL、headers 與 body)在每一種讀取中都屬於表格 moderator 的資料。三種 scope 的 GET .../triggers 依序套用兩道閘門:
| 呼叫者 | 回應 |
|---|---|
| 表格不存在、已在垃圾桶、屬於其他 scope、(自後端 5.21.0 起)屬於其他公司,或同公司但對這張表沒有任何權利 | 404 {"detail": "CustomTable Not found"},與未知 id 無法區分 |
| 對表格有權利、但不是表格 moderator | moderator 閘門的 403,例如公司表格回 Insufficient permissions. Required: table moderator or company management rights. |
| 表格 moderator | 200,回傳遮罩後的設定 |
每一種 trigger 寫入都使用同一道 moderator 閘門,403 與 404 的回應和 rules 路由相同,包括所屬聊天室已被刪除的聊天室資料表回 404 Chatroom Not found;它也決定誰能在表格 history 中看到 trigger 設定列(schema_triggers_update):其他人讀 history 時,這些列會從該頁與 total 中一併排除。Command composer 的 schema 工具同樣套用這道閘門。Trigger 的執行、trigger runs 與 IaC 不受影響。
唯寫機密
webhook 與 api_call action 中的憑證對每一位讀者都是唯寫的,moderator 也不例外。機密 slot 依結構判定,不靠猜測值的內容:
- URL 的 userinfo(
user:password@),算一個 slot; - 每個 URL query 參數的值,以名稱與出現次序定位(只計入有值的參數;單獨的
?flag不是 slot); - 每個 header 的值,以不分大小寫的名稱定位;
api_callbody 中每個純量葉節點,唯一例外是剛好等於$row.<欄位>的字串。
Scheme、host、port、path、fragment、header 名稱、query key 與 body key 仍然可見。Body 葉節點 "$row.<x>" 只有在 <x> 是這張表目前的顯示名稱或內部鍵時才會保持可見;否則它就和其他常值一樣是被遮蔽的 slot。每一種讀取(GET 與 PUT 的回傳、表格 history、IaC export、plan 與 apply 輸出、MCP triggers_get、command composer)都在每個 slot 顯示 "__SECRET__"。GET 與 PUT 的回應另外帶 secrets 清單,標出每個 slot 的位置,但絕不透露內容(沒有值、雜湊或前綴);只有表格 moderator 會收到的 schema_triggers_update 類型 history 列,則以 trigger_secrets: {"data": […], "old": […], "new": […]} 帶著同樣的 slot 物件,分別對應該列的 data.triggers、diff.old 與 diff.new(其他 history 列為 null)。只有 worker 在 action 觸發時才會使用儲存的值。
REST PUT .../triggers 與 IaC apply 新寫入的 schema_triggers_update history 列,也會在儲存前遮罩這些憑證。IaC trigger 基準會遮罩宣告中的憑證 slot;action 合約以外的 key 永遠進不了基準,因為 plan 在 parse 階段就會拒絕它(較早的版本是靜默移除這類 key)。較舊的 history 與 state 列需由操作人員執行 trigger-secrets 清理;它不變更即時 action 憑證或讀回格式。對 scope/system apply 後延遲清理舊 state 列的機制仍保留。
{
"table_id": "22222222-2222-4222-8222-222222222222",
"triggers": [{
"id": "trg_999999999999",
"name": "同步到 ERP",
"on": "created",
"actions": [{
"id": "act_444444444444",
"type": "api_call",
"method": "POST",
"url": "https://erp.example/orders?api_key=__SECRET__",
"headers": { "Authorization": "__SECRET__" },
"body": { "order_no": "$row.訂單編號", "source": "__SECRET__" }
}]
}],
"secrets": [
{ "trigger_id": "trg_999999999999", "action_id": "act_444444444444", "action_index": 0, "location": "url_query", "name": "api_key", "occurrence": 0, "path": null, "has_value": true },
{ "trigger_id": "trg_999999999999", "action_id": "act_444444444444", "action_index": 0, "location": "header", "name": "Authorization", "occurrence": null, "path": null, "has_value": true },
{ "trigger_id": "trg_999999999999", "action_id": "act_444444444444", "action_index": 0, "location": "body", "name": null, "occurrence": null, "path": ["source"], "has_value": true }
]
}每個 slot 中,action_index 是 action 從 0 起算的位置,name 是 query key 或 header 名稱(其他位置為 null),occurrence 是在同名且有值的參數之間從 0 起算的位置(只有 url_query),path 是 body 葉節點的物件鍵與陣列索引清單(只有 body),has_value 在儲存值非 null 且非空字串時為 true——空的 name= 參數、"" header,或 null、"" 的 body 葉節點則為 false。trigger_id 與 action_id 可能為 null。
PUT 時,對於送入 action 所綁定的已儲存 action,每個 slot 都遵循同一條規則:
| 送出內容 | 效果 |
|---|---|
在該 slot 放 "__SECRET__" | 保留已儲存的值 |
| 新值 | 取代原值 |
null | 清除:"<Header>": null 移除該 header,"headers": null / "body": null 移除整個欄位,body 葉節點為 null 則存成 JSON null。URL 字串內無法寫 null,因此空的 userinfo(scheme://@host)代表移除 userinfo,name= 則把該參數存成空值 |
| 不送 | 保留:省略 url、headers 或 body 欄位會保留已儲存的欄位;在有送出的欄位中缺少某個 header、query 參數、userinfo 或 body key,會保留其已儲存的值(已儲存值為空的 slot 則會被移除) |
因此把 GET 的 body 原封不動送回是安全的。保留的值只綁定一個身分,不會搬家:同 id 的已儲存 trigger,以及其中同 id 的 action(未知的 action id 視為新 action;同一個 id 送兩次會被拒絕)。沒有 id 的 action 只在對應關係明確時才綁定:先比對非機密設定完全相同者(相同設定的 action 數量一致時依位置對應),若只剩一個送入 action 與一個已儲存 action,再依相同型別與目的地,最後才依相同型別。有效目的地改變(URL 的 scheme、host 或 port,或任何大小寫的 Host header)時也會拒絕保留,所以改指向另一個目的地時,必須重新輸入憑證。被拒絕的保留不會儲存任何東西,並回傳物件形式 detail 的 400:
{
"detail": {
"error": "trigger_secret_keep_refused",
"code": "destination_changed",
"message": "triggers[0] action 1: the url's scheme, host or port, or its Host header, changed, so its stored secrets cannot be kept — re-enter every header, query, userinfo and body value for the new destination (or clear them)"
}
}code 說明原因;message 只會點名 slot,不會出現值,並以 triggers[<i>] action <n>: 開頭(n 從 1 起算)——trigger_id_reused 例外,它的內容是 triggers[<i>]: trigger id '<id>' is used by more than one trigger:
code | 何時拒絕 | 修正方式 |
|---|---|---|
unknown_action_id | action 帶的 id 不是同一個 trigger 已儲存的 action,而且含有 "__SECRET__"(沒有佔位符的未知 id 只是一個新 action) | 送已儲存的 action id,或送真正的值 |
action_id_reused | 機密還原階段,兩個 incoming webhook/api_call action 使用同一個已儲存 action id,即使沒有保留值也會拒絕;其他 action 類型的重複 id 則在型別檢查後由一般驗證處理 | 讓 id 不重複 |
trigger_id_reused | 逐一還原機密時遇到重複、非空的字串 trigger id;較早 trigger 的機密錯誤可能先於後面的重複 id 錯誤回報 | 讓 id 不重複 |
ambiguous_keep | 沒有 id 的 action 要保留已儲存的機密(佔位符或省略的 slot),但有好幾個同型別的已儲存 action 都可能提供它 | 送該 action 的 id,或送真正的值 |
no_stored_twin | 佔位符找不到可綁定的已儲存 action:新的 trigger 或 action、從別的 trigger 移過來的 action,或目的地或路徑已改變的無 id action | 送真正的值 |
type_changed | action 依 id 綁定到任何不同型別(不只 webhook ↔ api_call)的已儲存 action,而且送了佔位符 | 送真正的值 |
destination_changed | 有值要保留,但 URL 的 scheme、host 或 port,或 Host header,與已儲存的 action 不同 | 為新的目的地重新輸入或清除每個機密 |
nothing_to_keep | 佔位符放在綁定的已儲存 action 沒有的 slot(userinfo、該 occurrence 的 query 參數、header 名稱、body 路徑) | 送真正的值,或拿掉佔位符 |
duplicate_header | 同一個 action 送了兩次相同的 header 名稱(不分大小寫)——即使是沒有值要保留的新 action | 每個 header 只送一次 |
這個路由的其他 400 仍是純字串 detail,包括伺服器無法解析的佔位符會被最後一道防線拒絕(例如 header 值寫成 "Bearer __SECRET__"),所以請依 detail 是否為物件來分支處理。
Trigger PUT 屬於機密寫入路徑:它的 422 驗證 body 絕不回顯輸入(每筆只有 loc、type 與 msg: "validation_error:<type>"),request log 只保留遮罩標記,失敗也以不含值的方式記錄。離開伺服器的 trigger 設定摘要一律加上金鑰:command 的 dependency_contract.triggers_digest 是 tcm1: HMAC commitment,而不是讀者可以拿來比對猜測的純雜湊。
十種 action
type | 最小 shape | 用途 |
|---|---|---|
webhook | { "type": "webhook", "url": "https://...", "headers": {}? } | POST trigger-time row snapshot |
api_call | { "type": "api_call", "method": "POST", "url": "https://...", "headers": {}?, "body": {}? } | 可設定 GET/POST/PUT/PATCH/DELETE 的外部 JSON 呼叫 |
create_record | { "type": "create_record", "table_id": id?, "data": { "<欄位>": ... } } | 在相同 scope 或合法的同公司 ancestor scope 建立列;data 不可為空 |
update_record | { "type": "update_record", "table_id": id?, "target": "$row", "data": { "<欄位>": ... } } | 更新本列或一跳目標;明確 target table 使用相同的向上 scope matrix |
notify | { "type": "notify", "chatroom_id": id?, "message": "..." } | 對同公司的 chatroom 發訊息;chatroom 表預設使用自己的房間,department/company 表必須明訂 chatroom_id |
send_channel_message | { "type": "send_channel_message", "recipient": "created_by_client", "message": "..." } | 依資料列解析出一位社群客戶,推送一則訊息 |
invoke_command | { "type": "invoke_command", "command_id": id, "inputs": {} } | 以 trigger 作者的身分執行一個複合指令 |
delete_record | { "type": "delete_record", "target": "$row" } | 只軟刪除觸發它的那一列 |
materialize_slots | { "type": "materialize_slots", "target": {...}, "source": {...}, "horizon_days": 30 } | 在每日排程上把班表列展開成可預約時段 |
submit_sandbox_job | { "type": "submit_sandbox_job", "task_version_id": id, "input": {} } | 對 created/updated 資料列快照排入已授權的 Sandbox task version |
Actions 按陣列順序執行,遇到第一個失敗就停止。$row.<欄位> 可用於模板;api_call URL 的 scheme、host 與 port 必須是靜態的:URL 內只有 path/query 可插值,header 值與 body 葉節點則同樣可以放 $row token。字串模板在儲存時轉為 rename-stable key,執行時再從 trigger-time snapshot 取值;唯一例外是 notify.message,它以原文儲存,所以欄位改名會讓 notify 模板失效。
各種 action 的欄位、上限與失敗模式見動作型別。
一次執行是「誰」在做
由 trigger 寫出的資料列仍然標記為 trigger 的 created_by,但實際權限現在取決於是什麼造成這次執行:
| 觸發來源 | 資料列寫入以誰的權限執行 |
|---|---|
created / updated / deleted / restored | 造成該次寫入的 principal,且用他自己對目標表的權限 |
schedule | 系統權限,與以前相同 |
invoke_command(不論來源) | trigger 作者當下的有效權限 |
所以一個「只有管理者會寫資料時」運作良好的自動化,可能在低權限使用者觸發它的那一刻開始失敗,而失敗會以 ok: false 的 action receipt 呈現,整個 run 標記為 failed。webhook、api_call、notify、send_channel_message 不受這條 ACL 閘門限制。詳見 Grant 與 row policy。
提交、排隊、觀測
來源 transaction 會同時留下 run row;提交後才送入 worker。Run 使用同一個 chain_id 傳遞衍生寫入,最大 chain depth 為 3,避免 trigger 互相循環,且每條 chain 的 generated-run 預算為 1,000 個子 run。At-least-once 傳輸會以原子 pending → running 更新認領 run;同時送達的重複 worker 看到 rowcount=0 就會在任何 action 前退出。generated-run 計數器是內部欄位,不在 run 回應中。從 triggerRuns.list 讀取 status、attempts、error、payload、chain_id、depth 與逐 action 結果,再依執行紀錄與重試處理失敗。
Warning Trigger 不能與
channelSCP 規則共存,因為背景 action 沒有 acting room。require_approval的互斥有四條,每一條都回409 approval_trigger_conflict:create_record/update_record的目標表、delete_record的自身表、invoke_command所引用 command 會寫入的每一張表,以及materialize_slots的目標表。invariant可以與 trigger 共存,且會驗證 trigger 寫入。
時間型工作見排程觸發器。端到端組合 rules、trigger、callback 與 run observation,請依自動化指南或使用流程精靈。