Formbricks Workflows 数据模型与领域包:从节点图、触发器到运行时执行的完整解析
【免费下载链接】formbricksOpen Source Qualtrics Alternative项目地址: https://gitcode.com/GitHub_Trending/fo/formbricks
Formbricks Workflows 是 Formbricks 中用于在“用户提交问卷响应”等事件发生后自动执行业务动作(如自动发送邮件)的工作流能力。@formbricks/workflows是这套能力背后框架无关的领域包,它用一组 Zod schema 定义了工作流的持久化结构、可执行结构、触发器、动作、条件以及运行日志的全部契约。本文以 packages/workflows/README.md 及其数据模型文档 packages/workflows/src/types/README.md 为主线,结合仓库源码,完整讲解该包的设计边界、每个数据对象的字段语义与 JSON 示例、图形校验规则,以及如何安全地扩展新的动作与触发器。
包定位与设计边界:为什么 Workflows 需要独立成包
Formbricks Workflows 的核心诉求是:同一套工作流逻辑可以在多种运行时(runner、builder、API 层)之间复用。因此@formbricks/workflows被设计为一个领域层包,只负责定义和校验“工作流长什么样”,不关心它被存储、渲染或执行的具体环境。
从 packages/workflows/package.json 可以看到它的依赖面非常窄——运行时依赖只有zod(其余均为 lint / 测试 / 构建工具链),并对外暴露两个入口:
@formbricks/workflows:核心类型与校验(由 packages/workflows/src/index.ts 统一导出analytics、contracts、execution、recipients、types五个子模块);@formbricks/workflows/server:面向 runner 的服务端辅助入口(packages/workflows/src/server/index.ts)。
包本身在 packages/workflows/README.md 中明确了三条硬性边界:
- 不得导入
apps/web或任何 Next.js 专属 API——保持框架中立; - 运行时适配器保持薄而显式——执行相关的胶水代码必须显式、克制;
- 依赖收窄,应用/运行时关注点通过接口传入——应用侧可以通过接口把存储、发送邮件等能力注入进来,而不是让领域包直接依赖具体实现。
这种设计带来一个直接好处:工作流对象可以在前端 builder(用于编辑)、后端 API(用于持久化)与 runner(用于执行)之间安全地传递,而对象形状只定义一处。
核心抽象:一触发、多节点、有向边的持久化图
@formbricks/workflows的整套数据模型可以用四句话概括(见 packages/workflows/src/types/README.md):
- 一个工作流有且只有一个触发器(trigger);
- 触发器之后的工作由子节点(child node)表示;
- 边(edge)把节点连接成一张图;
- 运行负载(payload)与运行日志(run log)记录触发器被触发时实际发生了什么。
围绕这四句话,类型目录(packages/workflows/src/types/)被组织为actions/(动作)、triggers/(触发器)、conditions/(条件)、document.ts(图与文档)、runs.ts(运行数据)、common.ts(共享对象)与content.ts(内容辅助)。
其中有两个贯穿全文的关键概念:
config= 用户配置:触发器与动作上的config字段只存放用户在工作流编辑器中填写的配置。任何运行时数据(比如本次触发时的响应内容)都不能放进config,而应进入触发器负载、运行数据或运行日志。ui= 纯构建器元数据:只用于帮助工作流 builder 渲染画布,绝不能影响执行。
节点基类ZWorkflowNodeBase
所有节点(trigger、action、if_else)都扩展自 packages/workflows/src/types/common.ts 中的ZWorkflowNodeBase:
| 字段 | 类型 | 语义 |
|---|---|---|
id | string | 定义内稳定的节点 id,边(edge)通过它引用节点 |
type | "trigger"|"action"|"if_else" | 节点种类 |
label | string(可选) | 人类可读的节点标签,长度限制 1–120 |
ui | ZWorkflowNodeUi(可选) | 仅供构建器使用的元数据 |
画布位置ZWorkflowNodePosition与构建器元数据ZWorkflowNodeUi
ZWorkflowNodePosition表示节点在 workflow builder 画布中的坐标,只有x与y两个数字字段:
{ "x": 320, "y": 0 }ZWorkflowNodeUi是可选元数据:position(可选)、collapsed(可选,节点是否折叠渲染),并通过catchall(z.unknown())允许额外键——构建器可以自由添加颜色、视图偏好等展示信息而不必改动执行契约:
{ "collapsed": false, "color": "blue", "position": { "x": 0, "y": 0 } }动态数据引用ZWorkflowDataRef
ZWorkflowDataRef表示“运行时可用数据的一个引用”:它用点路径(dot path)指向运行上下文中的某个值,而不是把值拷贝进工作流定义:
| 字段 | 类型 | 语义 |
|---|---|---|
path | string | 运行上下文中的点路径 |
fallback | string(可选) | 路径无法解析时使用的兜底字符串 |
{ "fallback": "support@example.com", "path": "response.email" }工作流图对象:边、持久化定义与可执行定义
边ZWorkflowEdge
边是图中两个节点之间的连接(packages/workflows/src/types/document.ts):
| 字段 | 类型 | 语义 |
|---|---|---|
id | string | 定义内稳定的边 id |
source | string | 边起点的节点 id |
target | string | 边终点的节点 id |
sourceHandle | string(可选) | 源侧句柄;if_else节点的出边必须使用then或else |
targetHandle | string(可选) | 目标侧句柄,主要为 builder UI 与未来节点类型预留 |
{ "id": "trigger-send-email", "source": "trigger", "target": "send-email", "targetHandle": "input" }定义基类ZWorkflowDefinitionBase
ZWorkflowDefinitionBase是所有持久化工作流文档的公共形状,ZWorkflowDefinition与ZWorkflowExecutableDefinition都基于它做不同强度的校验:
| 字段 | 类型 | 语义 |
|---|---|---|
schemaVersion | number | 文档 schema 版本,默认1(常量WORKFLOW_SCHEMA_VERSION = 1) |
trigger | trigger 节点 | null | 工作流唯一触发器;草稿尚无触发器时为 null(源码中.nullable()注释明确说明) |
nodes | 子节点数组 | 触发器之后运行的节点,只能是 action 或 condition 节点,不能是 trigger |
edges | 边数组 | 触发器与子节点之间的连接 |
entryNodeId | string | null | 工作流入口,必须引用触发器节点 id;无触发器时为 null |
一个完整的持久化工作流文档示例(response.completed触发器 +send_email动作):
{ "schemaVersion": 1, "entryNodeId": "trigger", "trigger": { "id": "trigger", "type": "trigger", "triggerType": "response.completed", "config": { "surveyId": "cm9zr4mps000008l8btfy1vtz", "endingCardIds": [] } }, "nodes": [ { "id": "send-email", "type": "action", "actionType": "send_email", "config": { "to": "{{response.email}}", "from": "noreply@example.com", "replyTo": ["support@example.com"], "subject": "Thanks for your response", "body": "We received your response.", "attachResponseData": true, "includeHiddenFields": false, "includeVariables": false } } ], "edges": [ { "id": "trigger-send-email", "source": "trigger", "target": "send-email" } ] }持久化定义ZWorkflowDefinition:宽容的保存期校验
ZWorkflowDefinition通过superRefine(validateWorkflowGraph)对图做引用级校验(packages/workflows/src/types/document.ts 第 51–160 行)。它拒绝以下情况:
- 重复的节点 id(报
Duplicate workflow node id: ...); entryNodeId与触发器 id 不一致(无触发器时 entryNodeId 必须为 null);- 边引用了不存在的节点(source 或 target 不在节点集合中);
if_else节点的出边未使用then/elsesourceHandle,反之非if_else节点不允许使用这两个句柄;- 触发器出边超过一条。注意:只含触发器、没有任何节点和边的草稿是合法可保存的——宽松校验保证“保存工作进度”不被打断,而“必须有一条出边”是执行期的要求。
同时它对每个if_else节点强制要求恰好一条then出边与恰好一条else出边。
可执行定义ZWorkflowExecutableDefinition:严苛的运行期校验
ZWorkflowExecutableDefinition是当前 runner 真正能执行的定义快照。它收紧了两处类型:trigger与entryNodeId不再允许 null(草稿期空值在启用时被剔除)。在此之上,validateExecutableGraph(同文件第 175–267 行)叠加了更严格的规则:
- 触发器必须恰好一条出边(
An executable workflow must have exactly one outgoing trigger edge); - 每个子节点都必须从触发器可达(从 trigger 出发做 BFS/DFS,不可达节点报
unreachable node ids); - 图必须无环(基于三色标记的 DFS 环检测,
Executable workflow graph must be acyclic); if_else节点在当前版本中被拒绝执行(if_else nodes are not executable in this version of workflows)——条件分支已纳入数据模型并可通过持久化校验,但 runner 尚未支持;send_email动作必须内容非空:源码通过getBlankSendEmailContentFields(node.config)(packages/workflows/src/types/content.ts)找出空白的邮件字段并报错(send_email node ... is missing ...)。原因正如源码注释所述:持久化 schema 是宽容的以便保存半成品,但“可执行”的send_email必须有真正可发送的内容。
这个“保存宽容、执行严格”的双层设计,是理解整套 workflow 契约的钥匙。
触发器对象:以response.completed为例
枚举与判别字段
触发器 id 集中在 packages/workflows/src/types/triggers/enum.ts:当前只有RESPONSE_COMPLETED = "response.completed"一种。动作 id 同理,见 packages/workflows/src/types/actions/enum.ts:当前只有SEND_EMAIL = "send_email"。
一个容易被忽略但很重要的设计原则(README 中专门强调):判别字段(discriminator)挂在节点上(actionType/triggerType),而不是藏在config内部。这样config可以纯粹地只描述用户配置,且可以利用 Zod 的discriminatedUnion做精确的类型收窄。
触发器配置ZResponseCompletedTriggerConfig
“问卷响应完成”触发器的配置(packages/workflows/src/types/triggers/response-completed.ts):
| 字段 | 类型 | 语义 |
|---|---|---|
surveyId | cuid2 | 响应完成会触发工作流的问卷 id |
endingCardIds | cuid2 数组,默认[] | 应触发工作流的结束卡 id;空数组 = 所有结束 |
{ "surveyId": "cm9zr4mps000008l8btfy1vtz", "endingCardIds": ["cm9zr4q7i000108l84gozfggr"] }触发器节点ZWorkflowResponseCompletedTriggerNode
type恒为trigger,triggerType恒为response.completed,其余字段(id、label、ui)继承节点基类:
{ "id": "trigger", "type": "trigger", "triggerType": "response.completed", "label": "Survey response completed", "config": { "surveyId": "cm9zr4mps000008l8btfy1vtz", "endingCardIds": [] }, "ui": { "position": { "x": 0, "y": 0 } } }运行时负载ZWorkflowTriggerPayload
负载是运行时事件上下文而非用户配置,由 runner 在触发器触发时产生(注意源码中使用了.catchall(z.unknown())允许未来附加运行时元数据):
| 字段 | 类型 | 语义 |
|---|---|---|
type | "response.completed" | 负载类型 |
workspaceId | cuid2 | 运行所属的工作空间/租户上下文 |
surveyId | cuid2 | 收到响应的问卷 |
responseId | cuid2 | 触发工作流的已完成响应 |
endingCardId | cuid2(可选) | 响应到达的结束卡 |
data | record(可选) | 提供给动作与条件使用的触发器数据,如响应字段 |
{ "type": "response.completed", "workspaceId": "cm9zr4wsp000508l8y6nh9r2v", "surveyId": "cm9zr4mps000008l8btfy1vtz", "responseId": "cm9zr4rsp000708l8bqccpfrx", "endingCardId": "cm9zr4q7i000108l84gozfggr", "data": { "response": { "email": "jane@example.com", "score": 9 } } }动作对象:send_email的配置与运行语义
动作配置ZWorkflowSendEmailActionConfig
send_email是目前唯一的动作(packages/workflows/src/types/actions/send-email.ts):
| 字段 | 类型 | 语义 |
|---|---|---|
to | string | 收件人表达式:字面邮箱地址,或持有受访者邮箱的问卷问题/隐藏字段的 element id |
from | 发件人邮箱(语义见下文说明) | |
replyTo | email 数组 | 收件人回复时使用的地址 |
subject | string | 邮件主题,最大长度 998(MAX_SUBJECT_LENGTH,对齐 RFC 5322 行宽上限) |
body | string | 邮件正文,最大长度 100,000(MAX_BODY_LENGTH,Gmail 对超过约 102KB 的邮件会截断渲染) |
attachResponseData | boolean | 是否在邮件中包含响应数据 |
includeVariables | boolean(可选) | 附加响应数据时是否包含调查变量 |
includeHiddenFields | boolean(可选) | 附加响应数据时是否包含隐藏字段 |
{ "to": "{{response.email}}", "from": "noreply@example.com", "replyTo": ["support@example.com"], "subject": "Thanks for your response", "body": "We received your response.", "attachResponseData": true, "includeHiddenFields": false, "includeVariables": false }源码注释揭示的运行细节(重要安全语义)
该文件头部有一大段高质量注释,揭示了若干与表面字段不一致的真实运行语义,值得展开:
to的两类取值行为不同:若为字面邮箱地址,则必须属于能访问该工作流 workspace 的用户——启用/测试时校验失败会被拒绝,runner 也拒绝向其发送,防止把响应数据转发到任意外部邮箱(对应 ENG-2029)或被撤销访问权的人(ENG-2186);若为 element id,则解析为受访者自己的邮箱地址,不做 allowlist 校验。body支持 recall token:正文是 HTML,支持#recall:[elementId]/fallback:x#形式的回填 token,运行时基于响应展开后再经白名单清理,并包裹进品牌化的 Follow-ups 邮件模板;subject则原样使用,不应用 recall。from是“残留”字段:实际发件人始终来自部署的MAIL_FROM环境变量(与 Follow-ups 保持一致),from仅用于派生稳定的 Message-ID 域名。- 长度上限是刻意的分叉:
subject与body在此有长度上限而 Follow-ups 没有——因为持久化 schema 宽容、任何消费方(空白判定、recall 展开、清理)都会遍历 body,若不设上限唯一的封顶就是 16MB 的代理 body 限制。
这一整套语义的目标是与既有调查 Follow-ups 能力保持 1:1 字段对等(源码注释明确写到 “1:1 field parity with survey Follow-ups”),从而让两种能力共享同一套运行时渲染逻辑。
动作注册表与判别联合
动作的注册集中在一处(packages/workflows/src/types/actions/index.ts):
export const WORKFLOW_ACTION_CONFIG_SCHEMAS = { [WORKFLOW_ACTIONS.SEND_EMAIL]: ZWorkflowSendEmailActionConfig, } as const; export const ZWorkflowActionNode = z.discriminatedUnion("actionType", [ZWorkflowSendEmailActionNode]);触发器侧对应(packages/workflows/src/types/triggers/index.ts):ZWorkflowTriggerNode = z.discriminatedUnion("triggerType", [ZWorkflowResponseCompletedTriggerNode]),配置则通过TWorkflowTriggerConfigSchemas接口类型映射到ZResponseCompletedTriggerConfig。
条件对象:if_else分支(已建模、暂不可执行)
条件逻辑由三部分组成(packages/workflows/src/types/conditions/)。
单条条件ZWorkflowCondition
| 字段 | 类型 | 语义 |
|---|---|---|
id | string | 稳定的条件 id |
left | ZWorkflowDataRef | 被检查的值引用 |
operator | string | 比较运算符:equals、notEquals、lessThan、lessEqual、greaterThan、greaterEqual、contains、notContains、exists、notExists |
right | any(条件性) | 右侧比较值;存在性运算符exists/notExists必须省略right,其余运算符必须提供 |
{ "id": "score-high", "left": { "path": "response.score" }, "operator": "greaterThan", "right": 8 }条件组ZWorkflowConditionGroup
| 字段 | 类型 | 语义 |
|---|---|---|
id | string | 稳定组 id |
connector | "and"|"or" | and= 所有子项通过;or= 至少一个子项通过 |
conditions | 非空数组 | ZWorkflowCondition或嵌套的ZWorkflowConditionGroup,支持嵌套逻辑 |
{ "id": "qualified-response", "connector": "and", "conditions": [ { "id": "email-exists", "left": { "path": "response.email" }, "operator": "exists" }, { "id": "score-high", "left": { "path": "response.score" }, "operator": "greaterThan", "right": 8 } ] }分支节点ZWorkflowIfElseNode
type恒为if_else,config.condition是条件组。边规则:恰好一条出边用sourceHandle: "then",恰好一条出边用sourceHandle: "else"(持久化校验强制,且文档图校验在validateWorkflowGraph中实现)。该节点在通用工作流定义中合法,但当前 runner 不可执行(可执行定义会直接拒绝)。
{ "id": "condition", "type": "if_else", "label": "Qualified response", "ui": { "collapsed": true }, "config": { "condition": { "id": "qualified-response", "connector": "and", "conditions": [ { "id": "score-high", "left": { "path": "response.score" }, "operator": "greaterThan", "right": 8 } ] } } }运行与版本对象:一次触发如何被记录
工作流不只是“定义”,还包括“运行痕迹”。这些对象定义在 packages/workflows/src/types/runs.ts 并由 packages/workflows/src/types/index.ts 统一导出。
ZWorkflowTriggerRunPayload
触发器负载的运行时快照:继承ZWorkflowTriggerPayload全部字段,并增加triggeredAt(ISO 时间字符串,记录触发器被捕获的时刻)。
ZWorkflowRunLogInput/ZWorkflowRunLogOutput
单个步骤被捕获的任意输入/输出对象,形状取决于步骤类型与提供方:
{ "subject": "Thanks", "to": "jane@example.com" }{ "messageId": "message-1", "provider": "smtp" }ZWorkflowStepResult
单步执行结果(内存中或序列化后):
| 字段 | 类型 | 语义 |
|---|---|---|
stepId | string | 被执行步骤的节点 id |
stepType | string | 步骤类型,如send_email |
status | pending|running|succeeded|failed|skipped | 步骤状态 |
input/output | object(可选) | 步骤输入 / 输出 |
error | string(可选) | 错误消息 |
startedAt/finishedAt | ISO string(可选) | 开始 / 结束时间 |
{ "stepId": "send-email", "stepType": "send_email", "status": "failed", "input": { "subject": "Thanks", "to": "jane@example.com" }, "output": { "provider": "smtp" }, "error": "SMTP provider rejected the message", "startedAt": "2026-06-09T12:01:01.000Z", "finishedAt": "2026-06-09T12:01:03.000Z" }ZWorkflowRunData
运行级数据快照,把触发器负载与步骤结果存放在一起;允许额外键(如示例中的attempt,为未来重试元数据预留):
| 字段 | 类型 | 语义 |
|---|---|---|
trigger | ZWorkflowTriggerRunPayload(可选) | 触发器快照 |
steps | ZWorkflowStepResult数组,默认[] | 步骤结果 |
ZWorkflowVersion
不可变的已发布工作流定义,供运行引用:
| 字段 | 类型 | 语义 |
|---|---|---|
id | string | 版本 id |
workflowId | string | 所属工作流 id |
workspaceId | string | 工作空间/租户 id |
version | number | 单调递增的正整数版本号 |
definition | ZWorkflowExecutableDefinition | 可执行定义快照 |
publishedAt | ISO string | 发布时间 |
publishedBy | string | null(可选) | 发布该版本的用户 id |
ZWorkflowRunLog
一条持久化的运行日志行,是ZWorkflowStepResult的落库形态,增加id、runId、sequence(用于排序的序号)字段,且error、startedAt、finishedAt可为 null。
三组状态枚举
状态贯穿工作流生命周期、整次运行与单个步骤三个层级(见 packages/workflows/src/types/common.ts 与 packages/workflows/src/types/README.md):
- 工作流生命周期
ZWorkflowStatus:draft(可编辑、未激活)、enabled(激活)、disabled(停用但保留)、archived(软删除,默认读取排除); - 整次运行
ZWorkflowRunStatus:queued(排队中)、running(执行中)、completed(成功完成)、failed(出错结束)、canceled(完成前被终止); - 单步日志
ZWorkflowRunLogStatus:pending(未开始)、running(执行中)、succeeded(成功)、failed(出错)、skipped(有意跳过)。
扩展指南:如何新增动作与触发器
packages/workflows/README.md 给出了明确的扩展开箱步骤,结合源码可以看到每一步对应的落点。
新增一个动作(action):
- 在
src/types/actions/enum.ts中把动作 id 加入WORKFLOW_ACTIONS常量(枚举同时驱动ZWorkflowActionType的取值); - 在
src/types/actions/下新增 config schema 与 node schema(参考send-email.ts的模式:ZWorkflowXxxActionConfig+ZWorkflowXxxActionNode,node 通过ZWorkflowNodeBase.extend固定type: "action"与对应的actionType字面量); - 把 config schema 注册进
WORKFLOW_ACTION_CONFIG_SCHEMAS(packages/workflows/src/types/actions/index.ts); - 把 node schema 加入
ZWorkflowActionNode的discriminatedUnion("actionType", [...])列表; - 新增或更新 fixtures 与测试,用新动作校验一份完整的工作流文档。
新增一个触发器(trigger):
- 在
src/types/triggers/enum.ts中把触发器 id 加入WORKFLOW_TRIGGERS; - 在
src/types/triggers/下新增 config schema、需要的 payload schema 与 node schema(参考response-completed.ts); - 把 config schema 注册进
TWorkflowTriggerConfigSchemas接口映射(packages/workflows/src/types/triggers/index.ts); - 把 node schema 加入
ZWorkflowTriggerNode的discriminatedUnion("triggerType", [...]); - 新增或更新 fixtures 与测试。
贯穿始终的纪律:config schema 只聚焦用户提供的配置;任何运行时数据必须放进触发器负载、运行数据、运行日志或服务层输入,绝不能混入config。同时保持ui字段只承载构建器元数据、不影响执行。
测试与示例:契约的落地验证
该包配有完整的测试与 fixture,是理解契约语义的绝佳佐证:
- 图校验测试packages/workflows/src/types/index.test.ts:覆盖持久化定义与可执行定义各自拒绝/接受哪些文档;
- 内容校验测试packages/workflows/src/types/content.test.ts:验证
send_email空白字段判定与内容边界; - 服务层测试packages/workflows/src/services/workflows.service.test.ts:覆盖领域服务行为;
- handler 测试packages/workflows/src/handlers/workflows.handlers.test.ts 与执行计划测试packages/workflows/src/execution/plan.test.ts:验证 HTTP 处理抽象与执行计划生成。
fixtures 目录 packages/workflows/src/types/fixtures/ 提供了六份可直接参考的完整 JSON 样例:workflow-definition.full.json(持久化定义)、workflow-executable-definition.full.json(可执行定义)、workflow-trigger-payload.full.json(触发器负载)、workflow-run-data.full.json(运行数据)、workflow-run-log.full.json(运行日志)与workflow-version.full.json(版本快照)。新增动作/触发器时,README 明确要求“新增或更新 fixtures 和测试来校验包含新节点的完整工作流文档”。
此外,analytics/、contracts/、execution/、handlers/、recipients/与services/子模块分别承载定义摘要、契约漂移检测(spec-drift.test.ts)、执行规划、HTTP 处理抽象与收件人解析等能力,共同构成一个“定义、校验、规划、执行、记录”闭环的领域包。
小结
@formbricks/workflows通过“框架无关的领域包 + Zod 契约 + 双层校验”这套组合,把工作流的定义、校验与运行支撑完整地封装起来:ZWorkflowDefinition宽容地守护构建期草稿,ZWorkflowExecutableDefinition严格地守护运行期快照;response.completed触发器与send_email动作构成当前可用的最小闭环,if_else条件分支则已进入数据模型、等待 runner 支持。对于想要深度集成或扩展 Formbricks Workflows 的开发者,理解config/ui/payload 的三分法、节点判别字段的位置,以及“持久化宽容、执行严格”的校验哲学,是正确使用和扩展这套能力的关键。
【免费下载链接】formbricksOpen Source Qualtrics Alternative项目地址: https://gitcode.com/GitHub_Trending/fo/formbricks
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考