產生可預約時段:materialize_slots
materialize_slots 把一張班表資料列的表,掃成一張具體、可預約的時段列的表。你只需要宣告一次可用時間 — 每週樣板加上特定日期的例外 — 每日的掃描就會在第二張表裡維持一段滾動 horizon 的真實資料列,隨時可被查詢與認領。從此不必再手動插入任何時段。
它是一種 trigger action,因此設定方式與其他 action 一樣走 triggers.set 的 full-replace 流程;但它的行為和其他 action 都不同:它是沒有觸發資料列的滾動 horizon 掃描,不是逐列反應。
它只能掛在 daily schedule 上
materialize_slots 只在 on: "schedule" 且 schedule.type 為 "daily" 的 trigger 上合法。created/updated/deleted/restored、date_column_reached 與 interval 都會在設定階段被拒絕:
materialize_slots is only valid on on:"schedule" triggers with schedule.type "daily"
(a record-less rolling horizon sweep; created/updated/deleted/restored,
date_column_reached and interval are rejected)這是作者最常犯的第一個錯。這個 action 沒有觸發資料列,因此逐列事件無法提供它需要的東西;而 interval 掃描則會一天內重複跑同一段 horizon 卻毫無好處。daily schedule 本身(包含 materializer 會沿用的 timezone)見排程觸發器。
模型
固定是三個部分:
班表 Shifts 表 ──[ daily trigger:materialize_slots ]──▶ 時段 Slots 表
(每週樣板列 + (每個 provider ×
特定日期覆寫列) 日期 × 起始時間一列)
▲
對 (slot_date, slot_start,
provider) 的 `unique` 規則- 宿主表(trigger 所在的表)存放班表列:每位 provider、每個星期幾一列,另外可加上針對特定日期的一次性覆寫列。
- 目標表存放產生的時段,並遵循同公司的 link hierarchy:可在相同 scope;chatroom host 可向上指向自己的 department 或 company;department host 可指向自己的 company。向下、sibling、無關 department 與跨公司 target 都會被拒絕。
- 目標表上的
unique規則在實務上不是可選項。沒有它,一次併發的人工認領就會把整次掃描變成 failed run;有了它,掃描能自我修復。見 unique。
一個「時段」就是目標表裡的一列,由它的自然鍵識別:provider link 的目標 id、slot_date、slot_start。每個 provider × 日期 × 起始時間寫一列。
完整設定範例
PUT /private/module/custom_tables/company/tables/11111111-1111-4111-8111-111111111111/triggers
{
"triggers": [
{
"name": "materialize bookable slots",
"on": "schedule",
"schedule": { "type": "daily", "at": "01:30", "timezone": "Asia/Taipei" },
"actions": [
{
"type": "materialize_slots",
"target": {
"table_id": "22222222-2222-4222-8222-222222222222",
"slot_date": "時段日期",
"slot_start": "開始",
"slot_end": "結束",
"link_map": { "醫師": "醫師" },
"defaults": { "狀態": "Open" }
},
"source": {
"weekday_column": "星期",
"start_column": "班表開始",
"end_column": "班表結束",
"date_column": "覆寫日期",
"closed_column": "休診",
"filter": [
{ "column": "啟用", "op": "eq", "value": true }
]
},
"horizon_days": 30,
"slot_minutes": 30
}
]
}
]
}target — 時段寫到哪裡
| 欄位 | 必填 | 契約 |
|---|---|---|
table_id | 是 | 時段表,必須在同一間公司內可達。id 不存在或超出 scope 都是 400 target table not found or not accessible — 兩種情況訊息相同,所以「not found」也可能其實是「不是你的」。 |
slot_date | 是 | 目標表上的 date 欄,接收時段日期 YYYY-MM-DD。 |
slot_start / slot_end | 是 | 兩者必須同時是 string 或同時是 datetime;混用是 400 materialize_slots target.slot_start and target.slot_end must be the same column type。 |
link_map | 是 | {目標 link 欄: 來源 link 欄},1 或 2 組。兩側都必須是 link 欄,且每一組的兩個 link 欄必須指向同一張被連結的表。 |
defaults | 否 | {目標 stored 欄: 字面值},蓋在每一列產生的時段上。 |
時間格式依目標欄型別而定:datetime 欄收到 YYYY-MM-DD HH:MM,string 欄收到純 HH:MM。這個選擇決定了預約 UI 要怎麼解析、篩選要拿什麼比對,所以請在有資料之前就決定。
defaults 只能是字面值。因為沒有觸發資料列,$row token 一律被拒(materialize_slots defaults are literals — $row tokens are not allowed (a schedule trigger fires with no record));link 欄必須透過 link_map 設定而非 defaults;計算欄唯讀;defaults 的 key 若與 slot_date、slot_start、slot_end 或任一 link_map key 衝突也會被拒。
source — 掃描哪些班表列
以下欄位全部指向宿主表的欄位。
| 欄位 | 必填 | 契約 |
|---|---|---|
weekday_column | 是 | string 或 select。cell 值去除空白並轉小寫後,必須是 mon、tue、wed、thu、fri、sat、sun 其一。 |
start_column / end_column | 是 | 存 24 小時制 HH:MM 的 string 欄。 |
date_column | 否 | date 欄。cell 非空代表該列是那一天的一次性覆寫。 |
closed_column | 否 | boolean 欄。為真時代表該日關閉(覆寫列)或整列跳過(每週樣板列)。 |
filter | 否 | 最多 5 條 predicate,用來縮小參與掃描的班表列,與 trigger when 共用同一套 predicate 引擎。身分/日期 token($me、$today)會被拒絕 — trigger 沒有 acting user。 |
純量參數
| 欄位 | 必填 | 範圍 | 意義 |
|---|---|---|---|
horizon_days | 是 | 1..92 | 從今天起連續填滿幾個日曆日。 |
slot_minutes | 否 | 5..1440 | 固定時段長度。省略它就是每段班別產生剛好一個時段。 |
滾動視窗
每次執行都會產生 horizon_days 個連續日期,起點是宿主 daily schedule 時區下的今天(schedule.timezone,未設定則為 UTC),以該次 run 的觸發時間計算。
收據的 window 是 [today_local, today_local + horizon_days],而且第二個元素是不含的。horizon_days: 30 從 2026-07-25 起算時,收據是 ["2026-07-25", "2026-08-24"],而實際產生的最後一天是 2026-08-23。對帳時最容易出錯的就是這個第二個日期的 off-by-one。
在 DST 轉換當天,當地日期 — 以及整個視窗 — 可能比平常早或晚一個日曆日。這是有界且能自我修復的:時段建立以自然鍵去重,因此轉換日的位移最多只會少掃或重掃一天,下一次每日 tick 就會把 horizon 收斂回預期日期,不需要人工修補。
班表列:每週樣板與特定日期覆寫
每一列宿主資料依 date_column 的 cell 分類:
- 覆寫列 —
date_column有非空日期。它只適用於那一天,並對該(provider, 日期)完全取代每週樣板。若它的closed_column為真,該日對該 provider 完全不產生時段;否則只使用它自己的起訖時間。 - 每週樣板列 — 沒有覆寫日期。
closed_column為真時整列跳過;weekday cell 必須是那七個三字母值之一。其他值(Monday、1、空白)會記一筆警告並靜默跳過該列。
同一個 (provider, 星期幾) 的多個每週樣板列會累加,這就是分段班表的作法:同一個星期幾寫兩列,一列 09:00–12:00、一列 14:00–18:00,各自獨立切分。
有兩個優先序邊界值得知道:
- 只要該
(provider, 日期)存在任何覆寫列,該日就不再參考每週樣板。若那列覆寫的起訖 cell 無法解析而又沒有標記關閉,該日會產生零個時段,沒有回退、也沒有錯誤。 - 同一
(provider, 日期)只要有任一覆寫列的closed為真,就勝過其他同日覆寫列提供的班別。
若某一班表列的 provider link 帶有多個目標,它會產生一列同時帶著全部目標的時段,而不是每個 provider 一列 — 因為去重簽章是那組被連結 id 的集合,而整份清單會被寫進時段列。請確保每一列班表只對應一位 provider。(完全沒有 link 值的班表列仍會產生時段,只是 provider link 是空的。)
切分班別
一段班別 [start, end) 會被切成長度為 slot_minutes 的固定視窗,而最後一段不完整的視窗會被丟棄:
| 班別 | slot_minutes | 產生的時段 |
|---|---|---|
| 09:00–10:20 | 30 | 09:00、09:30 — 10:00 那段會超過 10:20,因此丟棄 |
| 09:00–10:30 | 30 | 09:00、09:30、10:00 |
| 09:00–17:00 | 省略 | 一個時段,09:00–17:00 |
結束時間早於或等於開始時間的班別不會產生任何時段。
去重,以及什麼算「已經存在」
在寫入任何東西之前,掃描會先讀取目標表中落在視窗內的既有時段。這個讀取把「日期是否落在視窗內」下推到 SQL,而不是把整張時段表載入記憶體;因此時段表可以累積多年歷史而不拖慢 tick。日期 cell 缺失、為 JSON null 或不是日期字串的時段列,永遠不會匹配到視窗日期,會直接被排除。
存在性掃描刻意沒有 soft-delete 過濾。已取消(soft-deleted)的時段仍然算「存在」,因此重新掃描不會把它復活。取消掉的時段就是取消掉了。
Materializer 只會插入。它從不更新或刪除既有時段列,而且 slot_end 不屬於自然鍵。兩個後果:
- 縮短或刪除班表列、更改
slot_minutes或班別時間,都不會動到已經產生的未來時段;過時的時段必須人工取消。 - 手動修改某個已產生時段的開始時間後,下一次掃描會在原本的開始時間重新建立一列新的,因為原本的自然鍵已經不存在了。
為什麼目標表需要 unique 規則
時段是透過真正的建立資料列路徑寫入的,因此目標表的規則、ACL、SCP 拒絕、計算欄與核准 backstop 全部適用。請在目標表上加一條對 (slot_date, slot_start, provider link) 的 unique 規則。這樣一來,在本次 tick 的存在性掃描之後、插入之前被建立的那一列,會拋出帶 conflicting_record_id / key_tuple 的結構化 409,materializer 會把它計入 skipped_existing 而不是讓整個 action 失敗。
這個窄範圍的捕捉只涵蓋一種情況:unique 規則的前置檢查。真正同時發生的交易衝突是由資料庫自己的 unique index 擋下的,錯誤形狀不同,不會被吞掉。其他任何例外也一樣 — no_overlap 違規、必填欄位被拒或任何其他例外,都會中止整個 action,該次 run 落在 failed。
Operator 會看到什麼
每次掃描都會在 run 的 action_results 寫下一張收據:
{
"type": "materialize_slots",
"ok": true,
"created": 412,
"skipped_existing": 1088,
"truncated": false,
"window": ["2026-07-25", "2026-08-24"]
}| 欄位 | 意義 |
|---|---|
created | 本次 tick 插入的時段列數 |
skipped_existing | 自然鍵已存在者 — 包含 soft-deleted 時段與 unique 衝突競態 |
truncated | 是否因 1000 筆插入上限提前結束 |
window | 宿主 schedule 時區下的 [第一個產生日期, 不含的結束日期] |
只能從終端狀態的 run 讀它。run 列一開始是 pending/running,非終端狀態下的 action_results 只是不完整或空的紀錄 — 請輪詢到 status 為 done 或 failed。失敗的 action 收據是短格式:{"type": "materialize_slots", "ok": false, "detail": "…"},截斷在 300 字元。
若時段表在設定儲存與 tick 之間被刪除,該次 run 會落在 failed,error 為 materialize_slots failed: target table no longer exists。
上限
| 上限 | 值 | 達到上限時的行為 |
|---|---|---|
| 每次 tick 插入的時段數 | 1000 | truncated: true 但 ok: true — 這是成功,不是錯誤。下一次每日 tick 會繼續填滿 horizon。 |
| 每次執行掃描的班表/覆寫列數 | 1000 | 依 created_at 排序、只取 live 列。live source 列超過 1000 時,在插入任何時段之前就失敗,錯誤為 materialize_slots source row limit exceeded; archive unused source rows or split the template table before retrying。 |
| Trigger 鏈深度 | 3 | 每一次時段插入都會以 depth + 1 觸發目標表自己的事件 trigger。 |
| 每條 chain 的 generated runs | 1000 | 超出預算會 raise TriggerFanoutLimitExceeded 並 fail closed。 |
大 insert horizon 的第一次掃描不完整是正常現象。但 live 班表樣板列超過 1000 的租戶必須封存不用的 source 列或拆表;filter 無法縮小已經超過掃描上限的表,因為 1000 列查詢會先跑。
若目標表本身有事件 trigger 且流量大,一次 tick 仍最多可能產生 1000 個子 run,現在也受 chain generated-run 預算約束。把 materializer 指向高度自動化的表之前,請先估算這個量。
權限與擁有者
Schedule 觸發的 run 帶系統權限:payload 的 actor 是 {"user_id": null, "client_id": null},因為沒有引發它的 principal。掃描不套用任何 per-principal 的 insert/edit 閘門,這就是為什麼沒有人登入時 materializer 仍能運作。時段列會以該 trigger 的 created_by moderator 身分寫入,因此每個時段的建立者顯示的就是那個人。
護欄
- 核准衝突。
materialize_slots不能指向帶有require_approval規則的表 — 否則每日 tick 都會死在核准 backstop。設定寫入會回 409,detail 直接指出是哪條規則:{"error": "approval_trigger_conflict", "table_id", "target_table_id", "target_table_name", "action_type": "materialize_slots", "rule_id"}。 - 欄位刪除在兩個方向都被擋。 刪除
source引用的宿主欄位(weekday/start/end/date/closed 或source.filter的欄位)是 409;刪除slot_date、slot_start、slot_end、link_map、defaults引用的目標欄位同樣是 409:Trigger '<name>' on table '<host>' materializes slots into this table using column ('<display>'); delete or edit the trigger first。要注意跨表的那道守衛只掃描完全同 scope 的兄弟表 — 若 materializer 是向上寫入 department/company scope 的時段表,目標側的欄位刪除不會被擋,改那張表的 schema 時請格外小心。 - Trigger echo 混用兩種 ref 形式。 在
GET .../triggers中,source的各個 ref 與link_map的值會是顯示名稱(它們是宿主表欄位);而target.slot_*、link_map的鍵與defaults的鍵,回顯的是目標表的內部col_<hex>鍵。設定 UI 不能假設回顯 action 裡每個 ref 都是顯示名稱。
在 IaC 中撰寫
JSONL bundle 可以在 materialize_slots action 裡寫 "table_id": "$table:<table ref>",IaC 通道會在驗證之前把它解析成 live table id。這正是讓預約模型能跨環境攜帶、而不是寫死某個環境時段表 id 的關鍵。
- 目標側的欄位 ref(
slot_date、slot_start、slot_end、link_map的鍵、defaults的鍵)以目標表的 ref 解析;link_map的值以宿主表的 ref 解析。 - 同一份 bundle 內的前向引用(目標已宣告但尚未 apply)不是 plan error;executor 會在延後的第二輪解析它。
- 若用的是 registry 未追蹤的原始 table id,它的欄位 ref 會原封不動保留,因此作者必須在那裡直接提供內部鍵。
- 線上 REST
PUT帶這個 token 會被拒:materialize_slots target.table_id '$table:' tokens are IaC-only (use a raw table id via REST)。 - Export 會把 live id 改寫回
$table:<ref>,並在 materializer 懸空時 fail-closed:若目標已不存在或不在匯出範圍內,export 會直接拋錯而不是輸出原始 UUID。
試試看
先在 IaC 工作台建立兩表的診所模型 — 一張班表、一張時段表 — 再依預約使用情境跑完整流程,包含掃描所倚賴的 unique 規則。