Skip to Content
核心概念觸發器產生可預約時段

產生可預約時段:materialize_slots

materialize_slots 把一張班表資料列的表,掃成一張具體、可預約的時段列的表。你只需要宣告一次可用時間 — 每週樣板加上特定日期的例外 — 每日的掃描就會在第二張表裡維持一段滾動 horizon 的真實資料列,隨時可被查詢與認領。從此不必再手動插入任何時段。

它是一種 trigger action,因此設定方式與其他 action 一樣走 triggers.set 的 full-replace 流程;但它的行為和其他 action 都不同:它是沒有觸發資料列的滾動 horizon 掃描,不是逐列反應。

它只能掛在 daily schedule 上

materialize_slots on: "schedule"schedule.type"daily" 的 trigger 上合法。createdupdateddeletedrestoreddate_column_reachedinterval 都會在設定階段被拒絕:

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_dateslot_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:MMstring 欄收到純 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_dateslot_startslot_end 或任一 link_map key 衝突也會被拒。

source — 掃描哪些班表列

以下欄位全部指向宿主表的欄位。

欄位必填契約
weekday_columnstringselect。cell 值去除空白並轉小寫後,必須是 montuewedthufrisatsun 其一。
start_column / end_column存 24 小時制 HH:MMstring 欄。
date_columndate 欄。cell 非空代表該列是那一天的一次性覆寫
closed_columnboolean 欄。為真時代表該日關閉(覆寫列)或整列跳過(每週樣板列)。
filter最多 5 條 predicate,用來縮小參與掃描的班表列,與 trigger when 共用同一套 predicate 引擎。身分/日期 token($me$today)會被拒絕 — trigger 沒有 acting user。

純量參數

欄位必填範圍意義
horizon_days1..92從今天起連續填滿幾個日曆日。
slot_minutes5..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 必須是那七個三字母值之一。其他值(Monday1、空白)會記一筆警告並靜默跳過該列。

同一個 (provider, 星期幾) 的多個每週樣板列會累加,這就是分段班表的作法:同一個星期幾寫兩列,一列 09:0012:00、一列 14:0018:00,各自獨立切分。

有兩個優先序邊界值得知道:

  • 只要該 (provider, 日期) 存在任何覆寫列,該日就不再參考每週樣板。若那列覆寫的起訖 cell 無法解析而又沒有標記關閉,該日會產生零個時段,沒有回退、也沒有錯誤。
  • 同一 (provider, 日期) 只要有任一覆寫列的 closed 為真,就勝過其他同日覆寫列提供的班別。

若某一班表列的 provider link 帶有多個目標,它會產生一列同時帶著全部目標的時段,而不是每個 provider 一列 — 因為去重簽章是那組被連結 id 的集合,而整份清單會被寫進時段列。請確保每一列班表只對應一位 provider。(完全沒有 link 值的班表列仍會產生時段,只是 provider link 是空的。)

切分班別

一段班別 [start, end) 會被切成長度為 slot_minutes 的固定視窗,而最後一段不完整的視窗會被丟棄

班別slot_minutes產生的時段
09:00–10:203009:0009:30 — 10:00 那段會超過 10:20,因此丟棄
09:00–10:303009:0009:3010:00
09:00–17:00省略一個時段,09:0017: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 列一開始是 pendingrunning,非終端狀態下的 action_results 只是不完整或空的紀錄 — 請輪詢到 statusdonefailed。失敗的 action 收據是短格式:{"type": "materialize_slots", "ok": false, "detail": "…"},截斷在 300 字元。

若時段表在設定儲存與 tick 之間被刪除,該次 run 會落在 failederrormaterialize_slots failed: target table no longer exists

上限

上限達到上限時的行為
每次 tick 插入的時段數1000truncated: trueok: true — 這是成功,不是錯誤。下一次每日 tick 會繼續填滿 horizon。
每次執行掃描的班表/覆寫列數1000created_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 runs1000超出預算會 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_dateslot_startslot_endlink_mapdefaults 引用的目標欄位同樣是 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_mapdefaults 的鍵,回顯的是目標表的內部 col_<hex> 鍵。設定 UI 不能假設回顯 action 裡每個 ref 都是顯示名稱。

在 IaC 中撰寫

JSONL bundle 可以在 materialize_slots action 裡寫 "table_id": "$table:<table ref>",IaC 通道會在驗證之前把它解析成 live table id。這正是讓預約模型能跨環境攜帶、而不是寫死某個環境時段表 id 的關鍵。

  • 目標側的欄位 ref(slot_dateslot_startslot_endlink_mapdefaults 的鍵)以目標表的 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 規則。

Last updated on