Skip to Content

定義版本 1

版本 1 是扁平的寫入 DSL:一串有序的 steps,每個 step 指定一張資料表與一個動作,並帶著把值綁定自宣告 inputs 的列樣板。它沒有讀取、沒有 join、也沒有宣告式 outputs。當 command 是純粹的寫入扇出——主檔加明細、狀態變更加稽核列——就用它;需要先查資料時再改用版本 2

形狀

{ "version": 1, "inputs": [], "rules": [], "callbacks": [], "triggers": [], "steps": [] }

版本 1 拒絕 outputsversion 1 does not support explicit outputs),而帶有 kind key 的 step 會被以 version 1 requires action-based steps 拒絕。

Inputs

每個參數都要宣告。沒宣告的不能送,沒宣告的也不會從請求中被讀取。

{ "name": "allocations", "description": "這筆付款的成本中心分攤。", "type": "array", "required": false, "nullable": false, "max_items": 50, "item_schema": { "cost_centre": "string", "share": "float", "note": "string?" } }
欄位契約
name1–64 字元。版本 1 的 placeholder 文法允許 input 名稱含中日韓字元;版本 2 不允許。
description≤1024 字元(2026-07-30 由 256 放寬)。
typestringintegerfloatbooleandatedatetimearrayobjectidentity:useridentity:social_media_client
required為 false 且值缺席時,所有依賴它的 binding 都會被省略
nullable預設 false。為 false 時,明確傳入 JSON null 是 400。
max_length字串長度上限。
min_items / max_items陣列邊界,各為 0–200。max_items 同時決定 authoring 期的展開估算——未設定時以 200 計。
item_schema陣列元素的扁平 {欄位: 型別} 對應表,型別限 stringintegerfloatbooleandatedatetimejson。欄位預設必填,型別後綴 ? 表示選填。

執行期的值驗證很嚴格:

  • date 必須是 "YYYY-MM-DD" 字串、datetime 必須是 "YYYY-MM-DD HH:MM" 字串——原生 date 物件會被拒絕。
  • integer 拒絕 boolean;float 接受 intfloat 並轉成 float。
  • identity:* 必須是語法有效的 UUID 字串,並且在執行期額外檢查是否與 command 的 scope 有關聯(400 {"error":"identity_not_associated","input":"<name>"})。在 department scope 的 command 上,identity:user 檢查的是使用者的 department_id 而不是公司——同公司但不同部門的同事會失敗。
  • 多餘的 key 是 400 unexpected input key(s): …;缺少必填 input 是 400 input 'x': required input missing

「缺席」不等於 null。省略一個選填 input,只有在 data會把該 binding 拿掉,因此套用的是資料表自己的欄位預設值。想用 null 來「清空」欄位會得到 400 input 'x': null is not allowed (declare nullable:true to bind null literally)

其他位置的選填 input 一旦缺席,是讓整次執行失敗,而不是默默消失:

選填 input 綁在哪裡省略它的結果
data該 key 被拿掉,套用欄位預設值。
match400 step '<name>': optional input cannot omit a match key
whereFail closed——本來就是如此。
$case 任一分支的 then,或 else400 step '<name>': optional input cannot omit a $case result

match 過去的行為與 data 相同:key 被默默丟掉,於是 updatedelete 的選列述詞被放寬,寫入打到比建置者鎖定範圍更多的資料列。現在這是硬失敗。

Steps

{ "name": "allocation", "table": "Payment allocations", "action": "insert", "for_each": "$input.allocations", "rows": [{ "data": { "Cost centre": "$item.cost_centre" } }] }

name 符合 ^[a-z0-9_]{1,32}$ 且在定義內唯一。table 在 authoring 時可用顯示名稱或 table id,儲存時一律改寫成 table id。actioninsertupdatedeletefor_each 必須剛好是 $input.<array-input>——它讓該 step 對陣列每個元素重複一次;空陣列合法地產生零次迭代;巢狀 for_each 會被拒絕。

列的形狀依動作固定:

動作列可帶
insert只有 data。帶 matchwhere 是錯誤。
updatedata,外加 matchwhere 恰好一個
deletematchwhere 恰好一個。帶 data 是錯誤。

match 是精確 key 查找——欄位 key,或字面上的 idwhere 是有界的述詞形式:由 {column, op, value} 葉節點以 and / or / not 組成的遞迴樹。運算子為 eqneqgtgteltlteinnot_incontainsnot_containsbetweenis_nullis_not_nullin / not_in 接受 1–100 個值,between 恰好接受 [low, high],群組接受 1–20 個子節點;整棵樹上限為深度 12、128 個節點。

每一列都宣告 max_rows(預設 1,範圍 1–1000)。執行器實際查詢 max_rows + 1 筆,並在超出時直接失敗,而不是靜默截斷。

Command 沒有 upsert。update 或 delete 匹配到 0 筆是 step 失敗並整體回滾——Step 'name[i]': no record matches <column>=<value>——絕不會改成新增。匹配數超過 max_rowsStep 'name[i]': where matched more than max_rows <N>,同樣整體回滾。

Binding 與 placeholder 詞彙

一個 binding 是字面值、剛好一個 placeholder 字串、上述兩者的一層陣列,或一個 $case 節點。沒有字串插值、也沒有巢狀陣列——"Order $input.order_no" 是純字串字面值,不是模板。

Placeholder意義限制
$input.<name>某個宣告 input 的值該 input 必須存在。
$item.<field>該 step for_each 陣列的當前元素只能在有宣告 for_each 的 step 內使用。
$steps.<step>.rows[<i>].id較早 step 產生的列 id只能往回引用、只能引用 insert step、只能取 id,且絕不能引用有 for_each 的 step。
$ctx.<key>伺服器注入的上下文封閉集合:user_idsocial_media_client_idcompany_idchatroom_iddepartment_id。絕不從請求 body 接受。

Insert 的列 id 在展開階段就先鑄好。這正是主檔→明細連結能在同一個 transaction 內完成的原因,也是審批計畫能被凍結的原因。

Command 文法與 ACL row policy 文法是互不相交的兩套。$me$me.department$now$today±Nd 不是 command token;請用 $ctx.user_id 表示操作者。Command 的 $today 是不帶參數的資料庫時鐘日期運算子,沒有偏移量。

$case 是唯一的條件式。它接受 1–20 組 {when, then} 分支加上選填的 else;每個 when 使用與 where 相同的述詞文法,作用在該 step 自己資料表的欄位上,而每個 then(與 else)都是一般的 binding。上限為深度 8、64 個節點。

{ "$case": [{ "when": { "column": "Balance", "op": "lte", "value": 0 }, "then": "Paid" }], "else": "Part paid" }

每個分支的 thenelse 都必須綁得出值。可能缺席的選填 input 根本不能出現在 $case 裡——執行會以 400 step '<name>': optional input cannot omit a $case result 失敗,datamatch 兩種情境皆然。分支要更晚才會對著鎖定的前影像挑選,所以「這個分支綁不出來」只能是錯誤,不能靠猜。另一種做法——某個未被選中的分支綁不出值時就丟掉整個 key——會讓欄位停在舊值、宣告的 else 從未套用,而執行仍然回報 succeeded

上限

版本 1 最多 20 個 steps,而最壞展開量——對每個 step 取(insert 算 1,其餘取該列的 max_rows)乘上 for_each 倍數(該 input 的 max_items,未設定時為 200),再全部加總——必須 ≤ 1000。請刻意地設定 max_items:一個沒有上限的陣列 input,即使呼叫者永遠只送三個元素,也會佔掉預算 200。

這個上限會被檢查兩次。Authoring 時直接拒絕定義;執行期則重新檢查具體計畫,回 400 {"error":"cap_exceeded","cap":1000,"actual":N}

在 REST authoring 路徑上,結構性錯誤不會列舉。整份驗證清單會被丟棄,你只會收到一個不列舉的 422 {"error":"invalid_expression","phase":"author","message":"Command definition is invalid","retryable":false,"details":{}}。本頁引用的那些逐 step 訊息,只有透過 JSONL IaC 的 plan 錯誤清單才會傳到呼叫者手上。請在 POST 之前先對照本頁自行驗證。

完整範例:登錄一筆付款

新增付款、扇出它的成本中心分攤、再結清發票——一次完成。

{ "name": "record_payment", "description": "登錄一筆付款、分攤到成本中心,並結清發票。", "tag_id": "55555555-5555-4555-8555-555555555555", "definition": { "version": 1, "inputs": [ { "name": "invoice_id", "type": "string", "required": true, "nullable": false, "max_length": 36 }, { "name": "amount", "type": "float", "required": true, "nullable": false }, { "name": "method", "type": "string", "required": false, "nullable": false, "max_length": 32 }, { "name": "allocations", "type": "array", "required": false, "nullable": false, "max_items": 50, "item_schema": { "cost_centre": "string", "share": "float", "note": "string?" } } ], "rules": [ { "name": "amount_positive", "phase": "before_execute", "assert": { "source": "$input.amount", "op": "gt", "value": 0 }, "message": "amount must be greater than zero" } ], "steps": [ { "name": "payment", "table": "Payments", "action": "insert", "rows": [ { "data": { "Invoice": "$input.invoice_id", "Amount": "$input.amount", "Method": "$input.method", "Recorded by": "$ctx.user_id" } } ] }, { "name": "allocation", "table": "Payment allocations", "action": "insert", "for_each": "$input.allocations", "rows": [ { "data": { "Payment": "$steps.payment.rows[0].id", "Cost centre": "$item.cost_centre", "Share": "$item.share", "Note": "$item.note" } } ] }, { "name": "settle", "table": "Invoices", "action": "update", "rows": [ { "match": { "id": "$input.invoice_id" }, "data": { "Status": { "$case": [{ "when": { "column": "Balance", "op": "lte", "value": 0 }, "then": "Paid" }], "else": "Part paid" } } } ] } ] } }

省略 method 會讓 Payments 的該欄位保持資料表預設值,而不是寫入 null。$steps.payment.rows[0].id 合法,因為 payment 是較早、沒有 for_eachinsert step;反過來從 allocation 擷取則會在 authoring 時被拒絕。

執行它會回傳逐 step 的寫入後列——這是版本 1 有、版本 2 沒有的東西:

{ "execution_id": "bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb", "status": "succeeded", "steps": [ { "name": "payment", "table_id": "77777777-7777-4777-8777-777777777777", "rows": [{ "id": "cccccccc-3333-4333-8333-cccccccccccc", "Amount": 1200.0 }] }, { "name": "allocation", "table_id": "88888888-8888-4888-8888-888888888888", "rows": [{ "id": "dddddddd-4444-4444-8444-dddddddddddd" }] } ] }

每個 step 的 rows 上限為 1000 筆。回應會丟掉所有值為 null 的 key,因此請把「key 不存在」視為 null。

寫入後列裡的身分 cell 會被加工,與 REST 讀取完全一致

上面那些 rows 裡的 usersocial_clientprincipal cell,過去是原樣回傳儲存字串;於是下一行用 GET .../records/{record_id} 讀同一筆資料,看起來卻像另一個物件。自 backend PR #1171 起,executor 會把每一份寫入後列交給 REST 單筆讀取所用的同一個共用加工器,並以該表自己的公司為錨點,因此兩個介面回出來的形狀一致:

  • principal cell 變成 {ref, kind, id, name, …}——ref 是要原樣寫回去的帶標籤字串,kindusersocial_clientchatroom
  • 舊的 user cell 維持 {id, name, username, is_deleted},舊的 social_client cell 維持 {id, platform, name}——刻意沒有 ref沒有 kind,因為前端就是靠 cell 形狀分辨這兩種欄位型別;
  • 在本租戶解析不出來的 id——懸空、跨租戶,或寫入當時公司無法解析——會保留原始字串,不會被捏造,也不會被丟掉。

完整讀取契約見 principal 欄位

有一個順序細節與 REST 不同,但不改變結果:REST 是先加工再移除隱藏欄位,而寫入後列在加工執行時早已被移除過。結果相同,因為加工器只改寫列中已存在的 key——被 column ACL 拿掉的欄位不可能在這裡復活。

Rules

最多 20 條具名 rule 會在執行做任何事之前先把關。name 符合 ^[a-z0-9_]{1,32}$ 且必須唯一;phasebefore_execute(預設)或 before_commitmessage必填的 1–512 字元。

{ "name": "amount_positive", "phase": "before_execute", "assert": { "source": "$input.amount", "op": "gt", "value": 0 }, "message": "amount must be greater than zero" }

source 只能是 $input.<name>$ctx.<key>——沒有別的,尤其不是 ACL 的 $me token。群組用 and / or / not,各接受 1–20 個子節點。版本 1 的 rule 失敗是 422 {"error":"command_rule_failed","rule":"<name>","message":"<rule message>"}

rule 這個 key 只有版本 1 才有。版本 2 的 rule 失敗改用 program error 封套,body 內沒有 rule 名稱。用 detail.rule 分支的用戶端會在每一個版本 2 command 上壞掉。

漂移行為

版本 1 的 command 在執行期不會重新驗證整份 dependency contract。它只預先驗證每個正規化後的欄位 key 仍然存在(Step 'name': column 'col_x' no longer exists on table)以及資料表仍在 scope 內(Step 'name': table 'id' no longer exists)。被引用資料表上不相干的 schema、rule 或 trigger 編輯會被容忍。

由伺服器維護的流程仍會替兩種 DSL version 刷新 stored contract。Column mutation、rule write、對應的 IaC 路徑、purge-cascade survivor repair 與 root cross-reference repair,只選舊 contract 仍 valid 的 commands,並在 mutation 的 commit 中重新編譯仍可接受新形狀者。原本 stale 或被新 mutation 弄成 invalid 的 command 保持 stale;非資料庫 refresh failure 絕不 veto business mutation。Multi-table purge/repair 依被改寫 table 自己的 scope 分組 probe;這是完整分組,因為每個 command closure 都是 exact-scope-closed,closure member 若不在 command 的精確 scope filter 內,authoring 就以 A referenced table does not exist in this scope 失敗。每個 participating scope 都會收到完整的 rewritten-table set。透過 no-dependent fast path 併發建立的新 command,仍可能依 mutation 前形狀編譯並落成 stale。Trigger write 採更強政策:所有 selected commands 都必須刷新成功,否則 trigger/history/IaC-state bundle 以 command_dependency_refresh_failed 回滾。完整差異與版本 2 runtime 規則見執行語意

延伸閱讀

Last updated on