定義版本 1
版本 1 是扁平的寫入 DSL:一串有序的 steps,每個 step 指定一張資料表與一個動作,並帶著把值綁定自宣告 inputs 的列樣板。它沒有讀取、沒有 join、也沒有宣告式 outputs。當 command 是純粹的寫入扇出——主檔加明細、狀態變更加稽核列——就用它;需要先查資料時再改用版本 2。
形狀
{
"version": 1,
"inputs": [],
"rules": [],
"callbacks": [],
"triggers": [],
"steps": []
}版本 1 拒絕 outputs(version 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?" }
}| 欄位 | 契約 |
|---|---|
name | 1–64 字元。版本 1 的 placeholder 文法允許 input 名稱含中日韓字元;版本 2 不允許。 |
description | ≤1024 字元(2026-07-30 由 256 放寬)。 |
type | string、integer、float、boolean、date、datetime、array、object、identity:user、identity: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 | 陣列元素的扁平 {欄位: 型別} 對應表,型別限 string、integer、float、boolean、date、datetime、json。欄位預設必填,型別後綴 ? 表示選填。 |
執行期的值驗證很嚴格:
date必須是"YYYY-MM-DD"字串、datetime必須是"YYYY-MM-DD HH:MM"字串——原生 date 物件會被拒絕。integer拒絕 boolean;float接受int或float並轉成 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 被拿掉,套用欄位預設值。 |
match | 400 step '<name>': optional input cannot omit a match key |
where | Fail closed——本來就是如此。 |
$case 任一分支的 then,或 else | 400 step '<name>': optional input cannot omit a $case result |
match 過去的行為與 data 相同:key 被默默丟掉,於是 update 或 delete 的選列述詞被放寬,寫入打到比建置者鎖定範圍更多的資料列。現在這是硬失敗。
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。action 為 insert、update 或 delete。for_each 必須剛好是 $input.<array-input>——它讓該 step 對陣列每個元素重複一次;空陣列合法地產生零次迭代;巢狀 for_each 會被拒絕。
列的形狀依動作固定:
| 動作 | 列可帶 |
|---|---|
insert | 只有 data。帶 match 或 where 是錯誤。 |
update | data,外加 match 與 where 恰好一個。 |
delete | match 與 where 恰好一個。帶 data 是錯誤。 |
match 是精確 key 查找——欄位 key,或字面上的 id。where 是有界的述詞形式:由 {column, op, value} 葉節點以 and / or / not 組成的遞迴樹。運算子為 eq、neq、gt、gte、lt、lte、in、not_in、contains、not_contains、between、is_null、is_not_null。in / 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_rows 是 Step '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_id、social_media_client_id、company_id、chatroom_id、department_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"
}每個分支的 then 與 else 都必須綁得出值。可能缺席的選填 input 根本不能出現在 $case 裡——執行會以 400 step '<name>': optional input cannot omit a $case result 失敗,data 與 match 兩種情境皆然。分支要更晚才會對著鎖定的前影像挑選,所以「這個分支綁不出來」只能是錯誤,不能靠猜。另一種做法——某個未被選中的分支綁不出值時就丟掉整個 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_each 的 insert 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 裡的 user、social_client、principal cell,過去是原樣回傳儲存字串;於是下一行用 GET .../records/{record_id} 讀同一筆資料,看起來卻像另一個物件。自 backend PR #1171 起,executor 會把每一份寫入後列交給 REST 單筆讀取所用的同一個共用加工器,並以該表自己的公司為錨點,因此兩個介面回出來的形狀一致:
principalcell 變成{ref, kind, id, name, …}——ref是要原樣寫回去的帶標籤字串,kind是user、social_client或chatroom;- 舊的
usercell 維持{id, name, username, is_deleted},舊的social_clientcell 維持{id, platform, name}——刻意沒有ref、沒有kind,因為前端就是靠 cell 形狀分辨這兩種欄位型別; - 在本租戶解析不出來的 id——懸空、跨租戶,或寫入當時公司無法解析——會保留原始字串,不會被捏造,也不會被丟掉。
完整讀取契約見 principal 欄位。
有一個順序細節與 REST 不同,但不改變結果:REST 是先加工再移除隱藏欄位,而寫入後列在加工執行時早已被移除過。結果相同,因為加工器只改寫列中已存在的 key——被 column ACL 拿掉的欄位不可能在這裡復活。
Rules
最多 20 條具名 rule 會在執行做任何事之前先把關。name 符合 ^[a-z0-9_]{1,32}$ 且必須唯一;phase 為 before_execute(預設)或 before_commit;message 是必填的 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 規則見執行語意。
延伸閱讀
- 定義版本 2——讀取、join、彙總與宣告式 outputs。
- 執行語意——呼叫
/execute之後發生什麼事。 - Commands 參考——路由、參數與完整錯誤表。