@gadmin2n/schematics 0.0.135 → 0.0.137
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/application/files/gadmin2-game-angle-demo/Dockerfile.server +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/compose-ctl.sh +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/canvas.prisma +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +14 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/config/ui/User.ts +4 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/docker-compose.md +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/docker-compose.yml +13 -92
- package/dist/lib/application/files/gadmin2-game-angle-demo/readme.md +105 -7
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/ensure-database.js +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +7 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/prisma.config.ts +9 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/gen-jwt.ts +85 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/notify-dev-servers.js +9 -8
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/wait-for-nest-ready.js +5 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +43 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow.seed.ts +9 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflows.ts +24 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas-export.dto.ts +27 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas-export.service.spec.ts +430 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas-export.service.ts +203 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.ts +36 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.module.ts +2 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.controller.spec.ts +54 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.controller.ts +245 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.module.ts +10 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.service.spec.ts +366 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/temporalTenantConfig/temporalTenantConfig.service.ts +110 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution.dto.ts +19 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +15 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +79 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +41 -19
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/start-prod.sh +5 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/README.md +37 -573
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/ecosystem.dev.config.cjs +12 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/package.json +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/header.tsx +3 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +39 -26
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/i18n.ts +3 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +3 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +3 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasEditPage.tsx +23 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasListPage.tsx +66 -10
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasPage.tsx +16 -7
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasToolbar.tsx +21 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasApi.ts +30 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/components/ExportModal.tsx +84 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/components/ImportModal.tsx +166 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/hooks/useCanvasHistory.ts +52 -13
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/DslView.tsx +6 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.tsx +6 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/editor.tsx +17 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +18 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.tsx +225 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/utilities/lazy-with-reload.ts +31 -0
- package/package.json +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-dsl-validate.spec.ts +0 -220
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-dsl-validate.ts +0 -132
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/TODO.md +0 -152
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/config/development-sql.yaml +0 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/docker-compose.yml +0 -25
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/package.json +0 -13
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/.dockerignore +0 -12
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/.env +0 -7
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/Dockerfile +0 -79
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/GRACEFUL-DEPLOYMENT.md +0 -270
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/SANDBOX.md +0 -122
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/package-lock.json +0 -4285
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/package.json +0 -28
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/code-execute.ts +0 -51
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/db-execute.ts +0 -85
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/db-query.ts +0 -35
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/http-request.ts +0 -54
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/index.ts +0 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/reporting.ts +0 -85
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/send-notification.ts +0 -47
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/config.ts +0 -13
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/condition.ts +0 -101
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/context.ts +0 -58
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/graph.ts +0 -184
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/helpers.ts +0 -133
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/node-types.ts +0 -96
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/types.ts +0 -77
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/validate.ts +0 -109
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/index.ts +0 -101
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/outbox-poller.ts +0 -382
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/activities/code-execute.test.ts +0 -44
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/activities/http-request.test.ts +0 -87
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/cron-trigger-workflow.test.ts +0 -20
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/helpers.test.ts +0 -225
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/node-type-consistency.test.ts +0 -101
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/validate.test.ts +0 -205
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/workflows/dsl-workflow.ts +0 -499
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/tsconfig.json +0 -19
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/vitest.config.ts +0 -8
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/yarn.lock +0 -1905
package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/outbox-poller.ts
DELETED
|
@@ -1,382 +0,0 @@
|
|
|
1
|
-
import { Client as PgClient } from "pg";
|
|
2
|
-
import {
|
|
3
|
-
Client,
|
|
4
|
-
Connection,
|
|
5
|
-
WorkflowExecutionAlreadyStartedError,
|
|
6
|
-
} from "@temporalio/client";
|
|
7
|
-
import { config } from "./config";
|
|
8
|
-
|
|
9
|
-
const POLL_INTERVAL = Number(process.env.EVENT_POLL_INTERVAL_MS) || 10_000; // 默认 10 秒
|
|
10
|
-
const BATCH_SIZE = 50;
|
|
11
|
-
const MAX_RETRIES = Number(process.env.OUTBOX_MAX_RETRIES) || 5;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Outbox Poller 控制句柄。stop() 用于优雅关闭:
|
|
15
|
-
* - 不再调度下一次 poll
|
|
16
|
-
* - 等待当前 in-flight poll 完成
|
|
17
|
-
* - 释放 PG 与 Temporal 连接
|
|
18
|
-
*/
|
|
19
|
-
export interface OutboxPollerHandle {
|
|
20
|
-
stop(): Promise<void>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Outbox Poller — 定时扫描 t_workflow_event_outbox 未处理事件,匹配并触发 workflow。
|
|
25
|
-
*
|
|
26
|
-
* 工作原理:
|
|
27
|
-
* 1. 业务表的 AFTER 触发器写入 outbox 行(同一事务,保证不丢)
|
|
28
|
-
* 2. 本轮询器每 N 秒扫描未处理行
|
|
29
|
-
* 3. 匹配 event_trigger workflow → 启动 Temporal 执行
|
|
30
|
-
* 4. 标记行为已处理
|
|
31
|
-
*
|
|
32
|
-
* 事务边界(重要):
|
|
33
|
-
* - 每个 outbox 行用一个独立的 PG 事务包裹:BEGIN → SELECT FOR UPDATE → 触发 workflow → UPDATE processed → COMMIT
|
|
34
|
-
* - workflowId 由 outbox.id 派生为确定性值(`wf-outbox-{id}-w{workflowId}`),
|
|
35
|
-
* 即使在 workflow.start 与 COMMIT 之间崩溃,下次 poll 重试时 Temporal 会抛
|
|
36
|
-
* WorkflowExecutionAlreadyStartedError,被本模块识别为幂等成功,不会丢事件、不会重复启动 workflow。
|
|
37
|
-
*/
|
|
38
|
-
export async function startOutboxPoller(): Promise<OutboxPollerHandle> {
|
|
39
|
-
const pg = new PgClient({ connectionString: config.database.url });
|
|
40
|
-
await pg.connect();
|
|
41
|
-
|
|
42
|
-
const temporalConn = await Connection.connect({
|
|
43
|
-
address: config.temporal.address,
|
|
44
|
-
});
|
|
45
|
-
const temporalClient = new Client({
|
|
46
|
-
connection: temporalConn,
|
|
47
|
-
namespace: config.temporal.namespace,
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
console.log(
|
|
51
|
-
`[OutboxPoller] Started. Polling every ${POLL_INTERVAL}ms, max retries: ${MAX_RETRIES}`,
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
const poll = async () => {
|
|
55
|
-
try {
|
|
56
|
-
// 1. 先 SELECT 候选 id(不锁),减少长事务持锁时间
|
|
57
|
-
const { rows: candidates } = await pg.query(
|
|
58
|
-
`SELECT id FROM t_workflow_event_outbox
|
|
59
|
-
WHERE processed = FALSE
|
|
60
|
-
ORDER BY created_at
|
|
61
|
-
LIMIT $1`,
|
|
62
|
-
[BATCH_SIZE],
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
if (candidates.length === 0) return;
|
|
66
|
-
console.log(`[OutboxPoller] Found ${candidates.length} candidate events`);
|
|
67
|
-
|
|
68
|
-
// 2. 逐行处理:每行一个独立事务,保证 workflow.start 与 outbox UPDATE 的原子性
|
|
69
|
-
for (const { id } of candidates) {
|
|
70
|
-
await processOneEvent(pg, temporalClient, Number(id));
|
|
71
|
-
}
|
|
72
|
-
} catch (err: any) {
|
|
73
|
-
console.error("[OutboxPoller] Poll error:", err.message);
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
// 自调度循环:每次 poll 完成后再排下一次,避免 setInterval 在 poll 慢于 POLL_INTERVAL 时重叠调用
|
|
78
|
-
let stopped = false;
|
|
79
|
-
let timer: NodeJS.Timeout | null = null;
|
|
80
|
-
let inFlight: Promise<void> | null = null;
|
|
81
|
-
|
|
82
|
-
const loop = async () => {
|
|
83
|
-
if (stopped) return;
|
|
84
|
-
inFlight = poll();
|
|
85
|
-
try {
|
|
86
|
-
await inFlight;
|
|
87
|
-
} finally {
|
|
88
|
-
inFlight = null;
|
|
89
|
-
if (!stopped) {
|
|
90
|
-
timer = setTimeout(loop, POLL_INTERVAL);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
};
|
|
94
|
-
void loop();
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
async stop() {
|
|
98
|
-
stopped = true;
|
|
99
|
-
if (timer) {
|
|
100
|
-
clearTimeout(timer);
|
|
101
|
-
timer = null;
|
|
102
|
-
}
|
|
103
|
-
// 等待当前正在执行的 poll 完成(含本轮所有事件处理)
|
|
104
|
-
if (inFlight) {
|
|
105
|
-
try {
|
|
106
|
-
await inFlight;
|
|
107
|
-
} catch {
|
|
108
|
-
// poll 内部已捕获错误并写日志,这里忽略
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
await pg.end();
|
|
113
|
-
} catch (err: any) {
|
|
114
|
-
console.error(
|
|
115
|
-
"[OutboxPoller] PG client close error:",
|
|
116
|
-
err?.message ?? err,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
try {
|
|
120
|
-
await temporalConn.close();
|
|
121
|
-
} catch (err: any) {
|
|
122
|
-
console.error(
|
|
123
|
-
"[OutboxPoller] Temporal connection close error:",
|
|
124
|
-
err?.message ?? err,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
console.log("[OutboxPoller] Stopped, resources released");
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* 处理单条 outbox 事件(一个事务)。
|
|
134
|
-
*
|
|
135
|
-
* 流程:
|
|
136
|
-
* BEGIN
|
|
137
|
-
* SELECT FOR UPDATE SKIP LOCKED
|
|
138
|
-
* ↓
|
|
139
|
-
* matchAndTrigger(INSERT instance + workflow.start,workflowId 由 outbox.id 派生)
|
|
140
|
-
* ↓
|
|
141
|
-
* ┌─ 全部成功: UPDATE processed=TRUE → COMMIT
|
|
142
|
-
* ├─ 还能重试: UPDATE retry_count(保持 processed=FALSE)→ COMMIT
|
|
143
|
-
* └─ 终态失败: UPDATE processed=TRUE + last_error → COMMIT
|
|
144
|
-
*/
|
|
145
|
-
async function processOneEvent(
|
|
146
|
-
pg: PgClient,
|
|
147
|
-
temporalClient: Client,
|
|
148
|
-
outboxId: number,
|
|
149
|
-
): Promise<void> {
|
|
150
|
-
await pg.query("BEGIN");
|
|
151
|
-
let inTransaction = true;
|
|
152
|
-
try {
|
|
153
|
-
// 行级锁:SKIP LOCKED 保证多实例 poller 不会同时处理同一行
|
|
154
|
-
const { rows } = await pg.query(
|
|
155
|
-
`SELECT id, event_name, payload, retry_count
|
|
156
|
-
FROM t_workflow_event_outbox
|
|
157
|
-
WHERE id = $1 AND processed = FALSE
|
|
158
|
-
FOR UPDATE SKIP LOCKED`,
|
|
159
|
-
[outboxId],
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
if (rows.length === 0) {
|
|
163
|
-
// 已被另一实例处理或已 processed
|
|
164
|
-
await pg.query("ROLLBACK");
|
|
165
|
-
inTransaction = false;
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const row = rows[0];
|
|
170
|
-
try {
|
|
171
|
-
const matched = await matchAndTrigger(
|
|
172
|
-
pg,
|
|
173
|
-
temporalClient,
|
|
174
|
-
Number(row.id),
|
|
175
|
-
row.event_name,
|
|
176
|
-
row.payload,
|
|
177
|
-
);
|
|
178
|
-
if (matched === 0) {
|
|
179
|
-
console.warn(
|
|
180
|
-
`[OutboxPoller] No workflow matched event "${row.event_name}" (id=${row.id}). Event consumed without action.`,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// 全部 workflow.start 成功(含幂等成功),原子提交"已处理"
|
|
185
|
-
await pg.query(
|
|
186
|
-
`UPDATE t_workflow_event_outbox
|
|
187
|
-
SET processed = TRUE, processed_at = NOW()
|
|
188
|
-
WHERE id = $1`,
|
|
189
|
-
[row.id],
|
|
190
|
-
);
|
|
191
|
-
await pg.query("COMMIT");
|
|
192
|
-
inTransaction = false;
|
|
193
|
-
} catch (err: any) {
|
|
194
|
-
// workflow.start / DB 操作失败 —— 回滚后用独立事务记录 retry 状态
|
|
195
|
-
await pg.query("ROLLBACK");
|
|
196
|
-
inTransaction = false;
|
|
197
|
-
|
|
198
|
-
const nextRetry = (row.retry_count ?? 0) + 1;
|
|
199
|
-
const errMsg = String(err?.message ?? err);
|
|
200
|
-
|
|
201
|
-
if (nextRetry >= MAX_RETRIES) {
|
|
202
|
-
console.error(
|
|
203
|
-
`[OutboxPoller] Event ${row.id} (${row.event_name}) failed ${nextRetry} times. Marking as terminal failure: ${errMsg}`,
|
|
204
|
-
);
|
|
205
|
-
await pg.query(
|
|
206
|
-
`UPDATE t_workflow_event_outbox
|
|
207
|
-
SET processed = TRUE, processed_at = NOW(), retry_count = $1, last_error = $2
|
|
208
|
-
WHERE id = $3`,
|
|
209
|
-
[nextRetry, errMsg, row.id],
|
|
210
|
-
);
|
|
211
|
-
} else {
|
|
212
|
-
console.error(
|
|
213
|
-
`[OutboxPoller] Event ${row.id} (${row.event_name}) failed (retry ${nextRetry}/${MAX_RETRIES}): ${errMsg}`,
|
|
214
|
-
);
|
|
215
|
-
await pg.query(
|
|
216
|
-
`UPDATE t_workflow_event_outbox
|
|
217
|
-
SET retry_count = $1, last_error = $2
|
|
218
|
-
WHERE id = $3`,
|
|
219
|
-
[nextRetry, errMsg, row.id],
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
} catch (txErr: any) {
|
|
224
|
-
// 事务级异常(PG 连接断等)— 尝试回滚兜底
|
|
225
|
-
if (inTransaction) {
|
|
226
|
-
try {
|
|
227
|
-
await pg.query("ROLLBACK");
|
|
228
|
-
} catch {
|
|
229
|
-
/* ignore */
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
console.error(
|
|
233
|
-
`[OutboxPoller] Tx error for outbox ${outboxId}:`,
|
|
234
|
-
txErr?.message ?? txErr,
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* 查找监听该 eventName 的 workflow 并启动执行。
|
|
241
|
-
*
|
|
242
|
-
* **幂等性**:workflowId 由 outboxId + workflow_id 派生为确定性值。
|
|
243
|
-
* 若先前 poll 已成功 start 但事务未 COMMIT 就崩溃,本次重试时
|
|
244
|
-
* Temporal 会抛 WorkflowExecutionAlreadyStartedError —— 视为幂等成功。
|
|
245
|
-
*
|
|
246
|
-
* @returns 实际匹配并启动(或确认已启动)的 workflow 数量;0 表示无人订阅。
|
|
247
|
-
*/
|
|
248
|
-
async function matchAndTrigger(
|
|
249
|
-
pg: PgClient,
|
|
250
|
-
temporalClient: Client,
|
|
251
|
-
outboxId: number,
|
|
252
|
-
eventName: string,
|
|
253
|
-
payload: Record<string, any>,
|
|
254
|
-
): Promise<number> {
|
|
255
|
-
// 查询所有已发布且启用的 workflow 的最新版本 DSL
|
|
256
|
-
const result = await pg.query(`
|
|
257
|
-
SELECT w.id AS workflow_id, wv.id AS version_id, wv.dsl
|
|
258
|
-
FROM t_workflow w
|
|
259
|
-
JOIN t_workflow_version wv ON wv.workflow_id = w.id
|
|
260
|
-
WHERE w.status = 'PUBLISHED'
|
|
261
|
-
AND w.is_enabled = true
|
|
262
|
-
AND wv.version = (
|
|
263
|
-
SELECT MAX(wv2.version)
|
|
264
|
-
FROM t_workflow_version wv2
|
|
265
|
-
WHERE wv2.workflow_id = w.id
|
|
266
|
-
)
|
|
267
|
-
`);
|
|
268
|
-
|
|
269
|
-
let matchedCount = 0;
|
|
270
|
-
|
|
271
|
-
for (const row of result.rows) {
|
|
272
|
-
const dsl = row.dsl;
|
|
273
|
-
if (!dsl?.nodes) continue;
|
|
274
|
-
|
|
275
|
-
// 找 event_trigger 节点且 eventName 匹配
|
|
276
|
-
const trigger = dsl.nodes.find(
|
|
277
|
-
(n: any) =>
|
|
278
|
-
n.type === "event_trigger" &&
|
|
279
|
-
matchEventName(n.config?.eventName, eventName),
|
|
280
|
-
);
|
|
281
|
-
if (!trigger) continue;
|
|
282
|
-
|
|
283
|
-
// 可选: filterExpression 过滤
|
|
284
|
-
if (trigger.config?.filterExpression) {
|
|
285
|
-
if (!evaluateFilter(trigger.config.filterExpression, payload)) continue;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// 确定性 workflowId(用于幂等保护)
|
|
289
|
-
const deterministicWorkflowId = `wf-outbox-${outboxId}-w${row.workflow_id}`;
|
|
290
|
-
|
|
291
|
-
// 创建(或复用)WorkflowInstance —— 用 (workflow_id, source_outbox_id) UNIQUE 做幂等 upsert。
|
|
292
|
-
// 崩溃恢复路径下 (workflow_id, outboxId) 相同 → 直接拿到上次的 instanceId,不会产生孤儿行。
|
|
293
|
-
// 一个 outbox 事件可被多个 workflow 订阅,每个 workflow 各自得到一条 instance。
|
|
294
|
-
const instanceUpsert = await pg.query(
|
|
295
|
-
`INSERT INTO t_workflow_instance
|
|
296
|
-
(workflow_id, version_id, status, context, creator, source_outbox_id, created_at, updated_at)
|
|
297
|
-
VALUES ($1, $2, 'PENDING', $3, 'event_trigger', $4, NOW(), NOW())
|
|
298
|
-
ON CONFLICT (workflow_id, source_outbox_id) DO UPDATE
|
|
299
|
-
SET updated_at = NOW()
|
|
300
|
-
RETURNING id, (xmax = 0) AS inserted`,
|
|
301
|
-
[row.workflow_id, row.version_id, JSON.stringify(payload), outboxId],
|
|
302
|
-
);
|
|
303
|
-
const instanceId = Number(instanceUpsert.rows[0].id);
|
|
304
|
-
const wasInserted = instanceUpsert.rows[0].inserted as boolean;
|
|
305
|
-
|
|
306
|
-
// 启动 Temporal workflow(幂等:同 workflowId 第二次启动会抛 AlreadyStartedError)
|
|
307
|
-
let temporalRunId = deterministicWorkflowId;
|
|
308
|
-
try {
|
|
309
|
-
const handle = await temporalClient.workflow.start("dslWorkflow", {
|
|
310
|
-
taskQueue: config.temporal.taskQueue,
|
|
311
|
-
workflowId: deterministicWorkflowId,
|
|
312
|
-
args: [
|
|
313
|
-
{
|
|
314
|
-
instanceId,
|
|
315
|
-
workflowId: Number(row.workflow_id),
|
|
316
|
-
versionId: Number(row.version_id),
|
|
317
|
-
dsl,
|
|
318
|
-
context: payload,
|
|
319
|
-
},
|
|
320
|
-
],
|
|
321
|
-
});
|
|
322
|
-
temporalRunId = handle.workflowId;
|
|
323
|
-
console.log(
|
|
324
|
-
`[OutboxPoller] Triggered workflow ${row.workflow_id} for event "${eventName}", instance=${instanceId}${wasInserted ? "" : " (recovered)"}, wfId=${deterministicWorkflowId}`,
|
|
325
|
-
);
|
|
326
|
-
} catch (err: any) {
|
|
327
|
-
if (err instanceof WorkflowExecutionAlreadyStartedError) {
|
|
328
|
-
// 上一轮 start 成功但 outbox 提交前崩溃 —— 这次重放视为幂等成功
|
|
329
|
-
console.warn(
|
|
330
|
-
`[OutboxPoller] Workflow ${deterministicWorkflowId} already exists (recovery). outboxId=${outboxId}, instance=${instanceId}`,
|
|
331
|
-
);
|
|
332
|
-
} else {
|
|
333
|
-
throw err; // 其它错误冒泡,触发上层 ROLLBACK + retry
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// 回写 temporal_run_id(即便走的是 recovery 分支也确保字段不为空)
|
|
338
|
-
await pg.query(
|
|
339
|
-
`UPDATE t_workflow_instance SET temporal_run_id = $1 WHERE id = $2`,
|
|
340
|
-
[temporalRunId, instanceId],
|
|
341
|
-
);
|
|
342
|
-
|
|
343
|
-
matchedCount++;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
return matchedCount;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* 匹配事件名,支持通配符:
|
|
351
|
-
* - "t_order.insert" 精确匹配
|
|
352
|
-
* - "t_order.*" 匹配该表所有操作
|
|
353
|
-
* - "*.insert" 匹配所有表的 insert
|
|
354
|
-
*/
|
|
355
|
-
function matchEventName(pattern: string | undefined, actual: string): boolean {
|
|
356
|
-
if (!pattern) return false;
|
|
357
|
-
if (pattern === actual) return true;
|
|
358
|
-
if (pattern === "*") return true;
|
|
359
|
-
|
|
360
|
-
const [patTable, patOp] = pattern.split(".");
|
|
361
|
-
const [actTable, actOp] = actual.split(".");
|
|
362
|
-
|
|
363
|
-
const tableMatch = patTable === "*" || patTable === actTable;
|
|
364
|
-
const opMatch = !patOp || patOp === "*" || patOp === actOp;
|
|
365
|
-
|
|
366
|
-
return tableMatch && opMatch;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* 简单的 filter 表达式求值。
|
|
371
|
-
*/
|
|
372
|
-
function evaluateFilter(
|
|
373
|
-
expression: string,
|
|
374
|
-
payload: Record<string, any>,
|
|
375
|
-
): boolean {
|
|
376
|
-
try {
|
|
377
|
-
const fn = new Function("data", `return ${expression}`);
|
|
378
|
-
return !!fn(payload);
|
|
379
|
-
} catch {
|
|
380
|
-
return true;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { codeExecute } from '../../activities/code-execute';
|
|
3
|
-
|
|
4
|
-
describe('codeExecute activity', () => {
|
|
5
|
-
it('executes simple script and returns result', async () => {
|
|
6
|
-
const output = await codeExecute({
|
|
7
|
-
script: 'return 1 + 2;',
|
|
8
|
-
});
|
|
9
|
-
expect(output.result).toBe(3);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it('has access to data context', async () => {
|
|
13
|
-
const output = await codeExecute({
|
|
14
|
-
script: 'return data.x * 2;',
|
|
15
|
-
data: { x: 5 },
|
|
16
|
-
});
|
|
17
|
-
expect(output.result).toBe(10);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('runOnceForEach mode processes each item', async () => {
|
|
21
|
-
const output = await codeExecute({
|
|
22
|
-
script: 'return item * 10;',
|
|
23
|
-
mode: 'runOnceForEach',
|
|
24
|
-
items: [1, 2, 3],
|
|
25
|
-
});
|
|
26
|
-
expect(output.result).toEqual([10, 20, 30]);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('respects timeout', async () => {
|
|
30
|
-
await expect(
|
|
31
|
-
codeExecute({
|
|
32
|
-
script: 'while(true) {}',
|
|
33
|
-
timeout: 100,
|
|
34
|
-
}),
|
|
35
|
-
).rejects.toThrow();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('sandbox cannot access Node.js globals', async () => {
|
|
39
|
-
const output = await codeExecute({
|
|
40
|
-
script: 'return typeof require;',
|
|
41
|
-
});
|
|
42
|
-
expect(output.result).toBe('undefined');
|
|
43
|
-
});
|
|
44
|
-
});
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import type { HttpRequestInput } from '../../activities/http-request';
|
|
3
|
-
|
|
4
|
-
// Mock axios at module level
|
|
5
|
-
vi.mock('axios', () => ({
|
|
6
|
-
default: vi.fn(),
|
|
7
|
-
}));
|
|
8
|
-
|
|
9
|
-
import axios from 'axios';
|
|
10
|
-
import { httpRequest } from '../../activities/http-request';
|
|
11
|
-
|
|
12
|
-
const mockedAxios = vi.mocked(axios);
|
|
13
|
-
|
|
14
|
-
describe('httpRequest activity', () => {
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
vi.clearAllMocks();
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('makes GET request and returns response', async () => {
|
|
20
|
-
mockedAxios.mockResolvedValueOnce({
|
|
21
|
-
status: 200,
|
|
22
|
-
data: { message: 'ok' },
|
|
23
|
-
headers: { 'content-type': 'application/json' },
|
|
24
|
-
} as any);
|
|
25
|
-
|
|
26
|
-
const result = await httpRequest({
|
|
27
|
-
url: 'https://api.example.com/data',
|
|
28
|
-
method: 'GET',
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
expect(result.status).toBe(200);
|
|
32
|
-
expect(result.data).toEqual({ message: 'ok' });
|
|
33
|
-
expect(mockedAxios).toHaveBeenCalledOnce();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('sends body for POST request', async () => {
|
|
37
|
-
mockedAxios.mockResolvedValueOnce({
|
|
38
|
-
status: 201,
|
|
39
|
-
data: { id: 1 },
|
|
40
|
-
headers: {},
|
|
41
|
-
} as any);
|
|
42
|
-
|
|
43
|
-
await httpRequest({
|
|
44
|
-
url: 'https://api.example.com/items',
|
|
45
|
-
method: 'POST',
|
|
46
|
-
body: { name: 'test' },
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const callConfig = mockedAxios.mock.calls[0][0] as any;
|
|
50
|
-
expect(callConfig.data).toEqual({ name: 'test' });
|
|
51
|
-
expect(callConfig.method).toBe('post');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('applies custom timeout', async () => {
|
|
55
|
-
mockedAxios.mockResolvedValueOnce({
|
|
56
|
-
status: 200,
|
|
57
|
-
data: null,
|
|
58
|
-
headers: {},
|
|
59
|
-
} as any);
|
|
60
|
-
|
|
61
|
-
await httpRequest({
|
|
62
|
-
url: 'https://api.example.com',
|
|
63
|
-
method: 'GET',
|
|
64
|
-
timeout: 5000,
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
const callConfig = mockedAxios.mock.calls[0][0] as any;
|
|
68
|
-
expect(callConfig.timeout).toBe(5000);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('disables redirects when followRedirects is false', async () => {
|
|
72
|
-
mockedAxios.mockResolvedValueOnce({
|
|
73
|
-
status: 302,
|
|
74
|
-
data: null,
|
|
75
|
-
headers: { location: '/new' },
|
|
76
|
-
} as any);
|
|
77
|
-
|
|
78
|
-
await httpRequest({
|
|
79
|
-
url: 'https://api.example.com/old',
|
|
80
|
-
method: 'GET',
|
|
81
|
-
followRedirects: false,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
const callConfig = mockedAxios.mock.calls[0][0] as any;
|
|
85
|
-
expect(callConfig.maxRedirects).toBe(0);
|
|
86
|
-
});
|
|
87
|
-
});
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import * as workflows from '../workflows/dsl-workflow';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Smoke test: confirm cronTriggerWorkflow is exported from dsl-workflow.ts so the
|
|
6
|
-
* Temporal SDK auto-registers it via the workflowsPath setting in worker/src/index.ts.
|
|
7
|
-
*
|
|
8
|
-
* We don't run a TestWorkflowEnvironment here — that requires booting a real Temporal
|
|
9
|
-
* Server. The actual schedule registration + workflow execution paths are covered by
|
|
10
|
-
* the server-side TemporalService unit tests.
|
|
11
|
-
*/
|
|
12
|
-
describe('cronTriggerWorkflow export', () => {
|
|
13
|
-
it('is exported as a function', () => {
|
|
14
|
-
expect(typeof workflows.cronTriggerWorkflow).toBe('function');
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('dslWorkflow is also still exported', () => {
|
|
18
|
-
expect(typeof workflows.dslWorkflow).toBe('function');
|
|
19
|
-
});
|
|
20
|
-
});
|