@pxp-nd/flow 1.1.27 → 1.1.30
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/BPM_Propossal.md +566 -0
- package/README.md +437 -1
- package/dist/controllers/Flow.js +1 -1
- package/dist/controllers/FlowInstance.d.ts +1 -0
- package/dist/controllers/FlowInstance.js +10 -14
- package/dist/controllers/NodeInstance.d.ts +1 -0
- package/dist/controllers/NodeInstance.js +11 -15
- package/dist/helpers/dbLock.d.ts +4 -0
- package/dist/helpers/dbLock.js +45 -0
- package/package.json +1 -1
package/BPM_Propossal.md
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
# BPM + Automation Platform — Design Proposal
|
|
2
|
+
|
|
3
|
+
**Status:** Draft
|
|
4
|
+
**Base:** Current Tempo Flows (`twf_*`, `EventHq`, SQS worker) — see [FLOWS.md](./FLOWS.md)
|
|
5
|
+
**Stack lean (v1):** NestJS + React + PostgreSQL + SQS/BullMQ
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Summary — what we want to build
|
|
10
|
+
|
|
11
|
+
We want a **new process platform** that keeps everything that already works in Tempo Flows (visual automation, action catalog, event + queue, multi-tenant templates) and extends it into a real **BPM** product.
|
|
12
|
+
|
|
13
|
+
**In short:**
|
|
14
|
+
|
|
15
|
+
| Today (Flows) | Tomorrow (BPM + Automation) |
|
|
16
|
+
|---------------|-----------------------------|
|
|
17
|
+
| Event → delay → email/push/controller | Same automations, cleaner engine |
|
|
18
|
+
| Mostly linear graphs | Gateways (branch / parallel) with clear UI |
|
|
19
|
+
| Little human workflow | Forms, approvals, assignees, task inbox |
|
|
20
|
+
| No first-class documents | Require / upload / generate documents |
|
|
21
|
+
| Internal controllers mainly | HTTP / webhooks / external connectors |
|
|
22
|
+
| Soft versioning | Immutable process versions |
|
|
23
|
+
| Magic vendor `55` | Explicit **SYSTEM / GROUP / TENANT** catalog scopes |
|
|
24
|
+
|
|
25
|
+
**Product shape (same engine, two modes):**
|
|
26
|
+
|
|
27
|
+
1. **Automation** — fire-and-forget graphs (billing alerts, COS push, funnels).
|
|
28
|
+
2. **BPM** — long-running cases (form → approve → documents → notify → close).
|
|
29
|
+
|
|
30
|
+
**Builder UX (keep):** left = action palette · center = canvas · right = node form (`schema_json`).
|
|
31
|
+
|
|
32
|
+
**Migration:** strangler pattern — keep `twf_*` running; migrate process-by-process (e.g. Safe Families first); do not big-bang rewrite.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 2. ER diagram (color legend)
|
|
37
|
+
|
|
38
|
+
| Color | Meaning |
|
|
39
|
+
|-------|---------|
|
|
40
|
+
| **Amber** | Exists today as `twf_*` → **renamed / evolved** |
|
|
41
|
+
| **Green** | **Completely new** (no direct `twf_*` equivalent) |
|
|
42
|
+
|
|
43
|
+
```mermaid
|
|
44
|
+
flowchart TB
|
|
45
|
+
classDef renamed fill:#FFF3CD,stroke:#B8860B,color:#000000,stroke-width:2px
|
|
46
|
+
classDef newtbl fill:#D1E7DD,stroke:#0F5132,color:#000000,stroke-width:2px
|
|
47
|
+
classDef legend fill:#F8F9FA,stroke:#6C757D,color:#000000
|
|
48
|
+
|
|
49
|
+
subgraph LEGEND["Legend"]
|
|
50
|
+
L1["AMBER = existing twf_* → renamed/evolved"]
|
|
51
|
+
L2["GREEN = completely new"]
|
|
52
|
+
L1:::legend
|
|
53
|
+
L2:::legend
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
subgraph TENANCY["Tenancy"]
|
|
57
|
+
TG["bpm_tenant_group"]:::newtbl
|
|
58
|
+
TGM["bpm_tenant_group_member"]:::newtbl
|
|
59
|
+
AG["bpm_action_group"]:::newtbl
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
subgraph DESIGN["Design-time"]
|
|
63
|
+
AT["bpm_action_type<br/>← twf_action_type"]:::renamed
|
|
64
|
+
A["bpm_action<br/>← twf_action"]:::renamed
|
|
65
|
+
PD["bpm_process_definition<br/>← twf_flow"]:::renamed
|
|
66
|
+
PV["bpm_process_version<br/>← split from twf_flow"]:::renamed
|
|
67
|
+
N["bpm_node<br/>← twf_node"]:::renamed
|
|
68
|
+
E["bpm_edge<br/>← twf_node_connection"]:::renamed
|
|
69
|
+
FD["bpm_form_definition"]:::newtbl
|
|
70
|
+
FV["bpm_form_version"]:::newtbl
|
|
71
|
+
DT["bpm_document_type"]:::newtbl
|
|
72
|
+
AP["bpm_approval_policy"]:::newtbl
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
subgraph RUNTIME["Runtime"]
|
|
76
|
+
PI["bpm_process_instance<br/>← twf_flow_instance"]:::renamed
|
|
77
|
+
AI["bpm_activity_instance<br/>← twf_node_instance"]:::renamed
|
|
78
|
+
TK["bpm_token"]:::newtbl
|
|
79
|
+
T["bpm_task"]:::newtbl
|
|
80
|
+
TA["bpm_task_action"]:::newtbl
|
|
81
|
+
DOC["bpm_document"]:::newtbl
|
|
82
|
+
MSG["bpm_message<br/>← twf_event"]:::renamed
|
|
83
|
+
JOB["bpm_job"]:::newtbl
|
|
84
|
+
AUD["bpm_audit_log<br/>← twf_flow_log"]:::renamed
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
TG --> TGM
|
|
88
|
+
A --> AG
|
|
89
|
+
TG --> AG
|
|
90
|
+
AT --> A
|
|
91
|
+
A --> N
|
|
92
|
+
PD --> PV
|
|
93
|
+
PV --> N
|
|
94
|
+
PV --> E
|
|
95
|
+
FD --> FV
|
|
96
|
+
FV -.-> N
|
|
97
|
+
AP -.-> N
|
|
98
|
+
DT -.-> N
|
|
99
|
+
PV --> PI
|
|
100
|
+
PI --> AI
|
|
101
|
+
PI --> TK
|
|
102
|
+
N --> AI
|
|
103
|
+
AI --> T
|
|
104
|
+
T --> TA
|
|
105
|
+
PI --> DOC
|
|
106
|
+
DT --> DOC
|
|
107
|
+
MSG --> PI
|
|
108
|
+
PI --> JOB
|
|
109
|
+
PI --> AUD
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```mermaid
|
|
113
|
+
erDiagram
|
|
114
|
+
bpm_tenant_group ||--o{ bpm_tenant_group_member : has
|
|
115
|
+
bpm_tenant_group ||--o{ bpm_action_group : scopes
|
|
116
|
+
bpm_action_type ||--o{ bpm_action : categorizes
|
|
117
|
+
bpm_action ||--o{ bpm_action_group : visible_in
|
|
118
|
+
bpm_action ||--o{ bpm_node : used_by
|
|
119
|
+
bpm_process_definition ||--o{ bpm_process_version : has
|
|
120
|
+
bpm_process_version ||--o{ bpm_node : contains
|
|
121
|
+
bpm_process_version ||--o{ bpm_edge : contains
|
|
122
|
+
bpm_form_definition ||--o{ bpm_form_version : has
|
|
123
|
+
bpm_form_version ||--o{ bpm_node : bound_to
|
|
124
|
+
bpm_approval_policy ||--o{ bpm_node : configures
|
|
125
|
+
bpm_document_type ||--o{ bpm_node : required_by
|
|
126
|
+
bpm_process_version ||--o{ bpm_process_instance : instantiates
|
|
127
|
+
bpm_process_instance ||--o{ bpm_activity_instance : has
|
|
128
|
+
bpm_process_instance ||--o{ bpm_token : active_at
|
|
129
|
+
bpm_node ||--o{ bpm_activity_instance : executes_as
|
|
130
|
+
bpm_activity_instance ||--o{ bpm_task : creates
|
|
131
|
+
bpm_task ||--o{ bpm_task_action : records
|
|
132
|
+
bpm_process_instance ||--o{ bpm_document : owns
|
|
133
|
+
bpm_document_type ||--o{ bpm_document : typed_as
|
|
134
|
+
bpm_message ||--o{ bpm_process_instance : starts_or_signals
|
|
135
|
+
bpm_process_instance ||--o{ bpm_job : schedules
|
|
136
|
+
bpm_process_instance ||--o{ bpm_audit_log : audited_by
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Color checklist**
|
|
140
|
+
|
|
141
|
+
| Amber — renamed / evolved | Green — new |
|
|
142
|
+
|---------------------------|-------------|
|
|
143
|
+
| `bpm_action_type` ← `twf_action_type` | `bpm_tenant_group` |
|
|
144
|
+
| `bpm_action` ← `twf_action` | `bpm_tenant_group_member` |
|
|
145
|
+
| `bpm_process_definition` ← `twf_flow` | `bpm_action_group` |
|
|
146
|
+
| `bpm_process_version` ← split from `twf_flow` | `bpm_form_definition` / `bpm_form_version` |
|
|
147
|
+
| `bpm_node` ← `twf_node` | `bpm_document_type` |
|
|
148
|
+
| `bpm_edge` ← `twf_node_connection` | `bpm_approval_policy` |
|
|
149
|
+
| `bpm_process_instance` ← `twf_flow_instance` | `bpm_token` |
|
|
150
|
+
| `bpm_activity_instance` ← `twf_node_instance` | `bpm_task` / `bpm_task_action` |
|
|
151
|
+
| `bpm_message` ← `twf_event` | `bpm_document` |
|
|
152
|
+
| `bpm_audit_log` ← `twf_flow_log` | `bpm_job` |
|
|
153
|
+
|
|
154
|
+
> Tenant identity maps to today’s `vendor_id`. Optional bridge tables `twf_origin_name` / `twf_field_map` can remain for legacy view-based automations; BPM case data lives in `bpm_process_instance.variables`.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## 3. Data dictionary
|
|
159
|
+
|
|
160
|
+
Conventions used below:
|
|
161
|
+
|
|
162
|
+
- `tenant_id` = vendor id (unless noted).
|
|
163
|
+
- JSON examples are illustrative.
|
|
164
|
+
- `visibility_scope`: `SYSTEM` | `GROUP` | `TENANT`.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
### 3.1 Tenancy
|
|
169
|
+
|
|
170
|
+
#### `bpm_tenant_group` *(new)*
|
|
171
|
+
|
|
172
|
+
Groups of tenants that share a catalog slice (e.g. business type).
|
|
173
|
+
|
|
174
|
+
| Column | Type | Description | Example |
|
|
175
|
+
|--------|------|-------------|---------|
|
|
176
|
+
| `id` | PK | Group id | `1` |
|
|
177
|
+
| `code` | varchar | Stable code | `SF` |
|
|
178
|
+
| `name` | varchar | Display name | `Safe Families` |
|
|
179
|
+
| `is_active` | bool | Soft enable | `true` |
|
|
180
|
+
|
|
181
|
+
#### `bpm_tenant_group_member` *(new)*
|
|
182
|
+
|
|
183
|
+
| Column | Type | Description | Example |
|
|
184
|
+
|--------|------|-------------|---------|
|
|
185
|
+
| `id` | PK | Row id | `10` |
|
|
186
|
+
| `tenant_id` | int | Vendor id | `674` |
|
|
187
|
+
| `tenant_group_id` | FK | Group | `1` (`SF`) |
|
|
188
|
+
|
|
189
|
+
#### `bpm_action_group` *(new)*
|
|
190
|
+
|
|
191
|
+
Links a GROUP-scoped action to one or more tenant groups.
|
|
192
|
+
|
|
193
|
+
| Column | Type | Description | Example |
|
|
194
|
+
|--------|------|-------------|---------|
|
|
195
|
+
| `action_id` | FK | Action | `111` |
|
|
196
|
+
| `tenant_group_id` | FK | Group that may use it | `1` (`SF`) |
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
### 3.2 Catalog (palette)
|
|
201
|
+
|
|
202
|
+
#### `bpm_action_type` *(← `twf_action_type`)*
|
|
203
|
+
|
|
204
|
+
Category in the left palette (Delay, Email, Controller, HTTP, Approval, …).
|
|
205
|
+
|
|
206
|
+
| Column | Type | Description | Example |
|
|
207
|
+
|--------|------|-------------|---------|
|
|
208
|
+
| `id` | PK | Type id | `28` |
|
|
209
|
+
| `code` | varchar | Stable type code | `CONTROLLER_EXECUTION` |
|
|
210
|
+
| `name` | varchar | Palette group label | `CONTROLLER EXECUTION` |
|
|
211
|
+
| `schema_json` | json | Shared UI schema for the type | `{ "timeout": { ... } }` |
|
|
212
|
+
| `is_start` | char(1) | Can be a start/trigger type | `N` |
|
|
213
|
+
| `is_delay` | char(1) | Timer-like behavior | `N` |
|
|
214
|
+
| `on_duplicate` | varchar | Hook when process is cloned | `duplicateEmailTemplate` |
|
|
215
|
+
| `is_active` | bool | Visible in palette | `true` |
|
|
216
|
+
|
|
217
|
+
#### `bpm_action` *(← `twf_action`)*
|
|
218
|
+
|
|
219
|
+
Reusable step/event definition (stable contract via `code`).
|
|
220
|
+
|
|
221
|
+
| Column | Type | Description | Example |
|
|
222
|
+
|--------|------|-------------|---------|
|
|
223
|
+
| `id` | PK | Action id | `111` |
|
|
224
|
+
| `action_type_id` | FK | Category | `28` |
|
|
225
|
+
| `code` | varchar | **Stable API key** (never hardcode numeric id in callers) | `SF SEND COS PUSH` |
|
|
226
|
+
| `name` | varchar | Builder label | `Send Push Notification to COS members` |
|
|
227
|
+
| `description` | text | Help text in palette | `Sends a push when a COS message is posted` |
|
|
228
|
+
| `schema_json` | json | Right-panel form for this action | `{ "subject": {...}, "message": {...} }` |
|
|
229
|
+
| `config_json_template` | json | Defaults / `{{ FIELD }}` placeholders | `{ "subject": "{{ FAMILY_NAME }}" }` |
|
|
230
|
+
| `controller_function` | varchar | Internal handler or connector key | `hq-nd/BusinessTypeSF/sendCircleOfSupportNotification` |
|
|
231
|
+
| `visibility_scope` | varchar | Who can use it | `GROUP` |
|
|
232
|
+
| `owner_tenant_id` | int null | Set when scope = `TENANT` | `null` |
|
|
233
|
+
| `is_enabled` | bool | Kill switch | `true` |
|
|
234
|
+
| `icon` | varchar | Palette icon | `mdi:bell-ring-outline` |
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
### 3.3 Process definition (design-time graph)
|
|
239
|
+
|
|
240
|
+
#### `bpm_process_definition` *(← `twf_flow` metadata)*
|
|
241
|
+
|
|
242
|
+
Logical process (many versions).
|
|
243
|
+
|
|
244
|
+
| Column | Type | Description | Example |
|
|
245
|
+
|--------|------|-------------|---------|
|
|
246
|
+
| `id` | PK | Definition id | `100` |
|
|
247
|
+
| `key` | varchar | Stable process key | `sf-new-cos-message` |
|
|
248
|
+
| `name` | varchar | Display name | `New COS Message` |
|
|
249
|
+
| `category` | varchar | `automation` or `bpm` | `automation` |
|
|
250
|
+
| `owner_tenant_id` | int null | Owning vendor; null for platform template | `674` |
|
|
251
|
+
| `visibility_scope` | varchar | Who may clone/subscribe | `GROUP` |
|
|
252
|
+
| `icon` | varchar | UI icon | `chatbubbles-outline` |
|
|
253
|
+
| `is_active` | bool | Soft delete | `true` |
|
|
254
|
+
|
|
255
|
+
#### `bpm_process_version` *(split from `twf_flow`)*
|
|
256
|
+
|
|
257
|
+
Immutable published graph. Running instances pin a version.
|
|
258
|
+
|
|
259
|
+
| Column | Type | Description | Example |
|
|
260
|
+
|--------|------|-------------|---------|
|
|
261
|
+
| `id` | PK | Version id | `45` |
|
|
262
|
+
| `definition_id` | FK | Parent definition | `100` |
|
|
263
|
+
| `version` | int | Monotonic version number | `3` |
|
|
264
|
+
| `status` | varchar | `draft` \| `published` \| `retired` | `published` |
|
|
265
|
+
| `published_at` | timestamp null | Publish time | `2026-07-15T12:00:00Z` |
|
|
266
|
+
| `graph_checksum` | varchar | Optional integrity hash | `sha256:…` |
|
|
267
|
+
|
|
268
|
+
#### `bpm_node` *(← `twf_node`)*
|
|
269
|
+
|
|
270
|
+
One element on the canvas.
|
|
271
|
+
|
|
272
|
+
| Column | Type | Description | Example |
|
|
273
|
+
|--------|------|-------------|---------|
|
|
274
|
+
| `id` | PK | Node id | `18200` |
|
|
275
|
+
| `version_id` | FK | Process version | `45` |
|
|
276
|
+
| `action_id` | FK null | Catalog action (null for pure gateways/start/end) | `111` |
|
|
277
|
+
| `element_type` | varchar | BPMN-ish type | `serviceTask` / `userTask` / `exclusiveGateway` / `startEvent` |
|
|
278
|
+
| `name` | varchar | Label on canvas | `Send Push Notification to COS members` |
|
|
279
|
+
| `is_init` | bool | Entry / trigger node | `false` |
|
|
280
|
+
| `config_json` | json | Values from right-panel form (`action_config_json` today) | `{ "subject": "{{ REGISTERED_BY_NAME }}", "message": "{{ MESSAGE }}" }` |
|
|
281
|
+
| `form_version_id` | FK null | Bound form (user tasks) | `12` |
|
|
282
|
+
| `approval_policy_id` | FK null | Approval rules | `3` |
|
|
283
|
+
| `document_type_id` | FK null | Required doc type | `null` |
|
|
284
|
+
| `position_x` / `position_y` | int | Canvas coordinates | `120`, `340` |
|
|
285
|
+
|
|
286
|
+
#### `bpm_edge` *(← `twf_node_connection`)*
|
|
287
|
+
|
|
288
|
+
Directed connection; may carry a condition (gateway branch).
|
|
289
|
+
|
|
290
|
+
| Column | Type | Description | Example |
|
|
291
|
+
|--------|------|-------------|---------|
|
|
292
|
+
| `id` | PK | Edge id | `900` |
|
|
293
|
+
| `version_id` | FK | Process version | `45` |
|
|
294
|
+
| `from_node_id` | FK null | Source; null = link into start | `18199` |
|
|
295
|
+
| `to_node_id` | FK | Target | `18200` |
|
|
296
|
+
| `name` | varchar null | Branch label | `approved` |
|
|
297
|
+
| `condition_expr` | varchar null | Expression for XOR/default | `variables.decision == "approved"` |
|
|
298
|
+
| `is_default` | bool | Default flow on gateway | `false` |
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
### 3.4 Forms, approvals, documents (design-time)
|
|
303
|
+
|
|
304
|
+
#### `bpm_form_definition` *(new)*
|
|
305
|
+
|
|
306
|
+
| Column | Type | Description | Example |
|
|
307
|
+
|--------|------|-------------|---------|
|
|
308
|
+
| `id` | PK | Form id | `5` |
|
|
309
|
+
| `key` | varchar | Stable key | `sf-need-review` |
|
|
310
|
+
| `name` | varchar | Display name | `Need review form` |
|
|
311
|
+
| `visibility_scope` | varchar | Catalog scope | `GROUP` |
|
|
312
|
+
| `owner_tenant_id` | int null | If `TENANT` | `null` |
|
|
313
|
+
|
|
314
|
+
#### `bpm_form_version` *(new)*
|
|
315
|
+
|
|
316
|
+
| Column | Type | Description | Example |
|
|
317
|
+
|--------|------|-------------|---------|
|
|
318
|
+
| `id` | PK | Form version id | `12` |
|
|
319
|
+
| `form_id` | FK | Parent form | `5` |
|
|
320
|
+
| `version` | int | Version number | `2` |
|
|
321
|
+
| `schema_json` | json | JSON Schema of fields | `{ "type":"object", "properties": { "notes": { "type":"string" } } }` |
|
|
322
|
+
| `ui_schema` | json | UI layout hints | `{ "notes": { "ui:widget":"textarea" } }` |
|
|
323
|
+
| `status` | varchar | `draft` \| `published` | `published` |
|
|
324
|
+
|
|
325
|
+
#### `bpm_approval_policy` *(new)*
|
|
326
|
+
|
|
327
|
+
| Column | Type | Description | Example |
|
|
328
|
+
|--------|------|-------------|---------|
|
|
329
|
+
| `id` | PK | Policy id | `3` |
|
|
330
|
+
| `code` | varchar | Stable code | `SF_AREA_ANY` |
|
|
331
|
+
| `policy_type` | varchar | `ANY` \| `ALL` \| `N_OF_M` | `ANY` |
|
|
332
|
+
| `quorum_n` | int null | Required when `N_OF_M` | `null` |
|
|
333
|
+
| `assignee_resolver` | json | How to resolve approvers | `{ "type":"expression", "resolver":"sfAreaApprovers", "args": { "familyAreaId": "{{ variables.familyAreaId }}" } }` |
|
|
334
|
+
| `outcomes` | json | Allowed outcomes | `["approved","rejected","changes_requested"]` |
|
|
335
|
+
|
|
336
|
+
#### `bpm_document_type` *(new)*
|
|
337
|
+
|
|
338
|
+
| Column | Type | Description | Example |
|
|
339
|
+
|--------|------|-------------|---------|
|
|
340
|
+
| `id` | PK | Doc type id | `7` |
|
|
341
|
+
| `code` | varchar | Stable code | `HOST_AGREEMENT` |
|
|
342
|
+
| `name` | varchar | Label | `Host agreement PDF` |
|
|
343
|
+
| `mime_allowlist` | json | Allowed MIME types | `["application/pdf"]` |
|
|
344
|
+
| `max_size_mb` | int | Upload limit | `10` |
|
|
345
|
+
| `visibility_scope` | varchar | Catalog scope | `GROUP` |
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
### 3.5 Runtime
|
|
350
|
+
|
|
351
|
+
#### `bpm_process_instance` *(← `twf_flow_instance`)*
|
|
352
|
+
|
|
353
|
+
One running (or completed) case.
|
|
354
|
+
|
|
355
|
+
| Column | Type | Description | Example |
|
|
356
|
+
|--------|------|-------------|---------|
|
|
357
|
+
| `id` | PK | Instance id | `5001` |
|
|
358
|
+
| `version_id` | FK | Pinned process version | `45` |
|
|
359
|
+
| `tenant_id` | int | Owning vendor | `674` |
|
|
360
|
+
| `business_key` | varchar | External correlation id | `cos-message:8891` |
|
|
361
|
+
| `status` | varchar | `running` \| `waiting` \| `completed` \| `failed` \| `cancelled` | `running` |
|
|
362
|
+
| `variables` | json | Case data (replaces heavy reliance on SQL views) | `{ "referralId": 321, "familyName": "The Smith Family", "familyInitials": "SM", "message": "Can someone cover Friday?" }` |
|
|
363
|
+
| `started_at` / `ended_at` | timestamp | Lifecycle | `2026-07-16T14:01:00Z` / `null` |
|
|
364
|
+
|
|
365
|
+
#### `bpm_activity_instance` *(← `twf_node_instance`)*
|
|
366
|
+
|
|
367
|
+
One execution of a node inside an instance.
|
|
368
|
+
|
|
369
|
+
| Column | Type | Description | Example |
|
|
370
|
+
|--------|------|-------------|---------|
|
|
371
|
+
| `id` | PK | Activity id | `80001` |
|
|
372
|
+
| `instance_id` | FK | Process instance | `5001` |
|
|
373
|
+
| `node_id` | FK | Definition node | `18200` |
|
|
374
|
+
| `status` | varchar | `pending` \| `active` \| `completed` \| `failed` \| `skipped` | `completed` |
|
|
375
|
+
| `input_json` | json | Snapshot of inputs | `{ "subject": "Jane Doe", "message": "…" }` |
|
|
376
|
+
| `output_json` | json | Snapshot of outputs | `{ "sent": 3, "skipped": 1 }` |
|
|
377
|
+
| `error_message` | text null | Failure detail | `null` |
|
|
378
|
+
| `started_at` / `completed_at` | timestamp | Timing | … |
|
|
379
|
+
|
|
380
|
+
#### `bpm_token` *(new)*
|
|
381
|
+
|
|
382
|
+
Active position(s) in the graph (required for parallel gateways).
|
|
383
|
+
|
|
384
|
+
| Column | Type | Description | Example |
|
|
385
|
+
|--------|------|-------------|---------|
|
|
386
|
+
| `id` | PK | Token id | `60` |
|
|
387
|
+
| `instance_id` | FK | Process instance | `5001` |
|
|
388
|
+
| `node_id` | FK | Current node | `18200` |
|
|
389
|
+
| `status` | varchar | `active` \| `consumed` | `active` |
|
|
390
|
+
| `parent_token_id` | FK null | For parallel forks | `null` |
|
|
391
|
+
|
|
392
|
+
#### `bpm_task` *(new)*
|
|
393
|
+
|
|
394
|
+
Human inbox item (form / approval).
|
|
395
|
+
|
|
396
|
+
| Column | Type | Description | Example |
|
|
397
|
+
|--------|------|-------------|---------|
|
|
398
|
+
| `id` | PK | Task id | `900` |
|
|
399
|
+
| `activity_id` | FK | Related activity | `80010` |
|
|
400
|
+
| `instance_id` | FK | Process instance (denormalized for queries) | `5001` |
|
|
401
|
+
| `tenant_id` | int | Tenant | `674` |
|
|
402
|
+
| `task_type` | varchar | `FORM` \| `APPROVAL` | `APPROVAL` |
|
|
403
|
+
| `title` | varchar | Inbox title | `Approve posted need #441` |
|
|
404
|
+
| `assignee` | varchar null | Claimed user id/email | `approver@sf.org` |
|
|
405
|
+
| `candidate_users` | json | Potential assignees | `["a@sf.org","b@sf.org"]` |
|
|
406
|
+
| `candidate_groups` | json | Role/group keys | `["SF_AREA_12"]` |
|
|
407
|
+
| `due_at` | timestamp null | SLA due | `2026-07-17T18:00:00Z` |
|
|
408
|
+
| `status` | varchar | `open` \| `completed` \| `cancelled` | `open` |
|
|
409
|
+
| `form_version_id` | FK null | Form to render | `12` |
|
|
410
|
+
| `payload_json` | json | Task-specific data | `{ "needId": 441, "kind": "post" }` |
|
|
411
|
+
|
|
412
|
+
#### `bpm_task_action` *(new)*
|
|
413
|
+
|
|
414
|
+
Audit of human actions on a task.
|
|
415
|
+
|
|
416
|
+
| Column | Type | Description | Example |
|
|
417
|
+
|--------|------|-------------|---------|
|
|
418
|
+
| `id` | PK | Action id | `1200` |
|
|
419
|
+
| `task_id` | FK | Task | `900` |
|
|
420
|
+
| `actor` | varchar | Who acted | `a@sf.org` |
|
|
421
|
+
| `action` | varchar | `approve` \| `reject` \| `comment` \| `reassign` \| `complete` | `approve` |
|
|
422
|
+
| `comment` | text null | Optional note | `Looks good` |
|
|
423
|
+
| `data_json` | json null | Submitted form fields | `{ "notes": "OK" }` |
|
|
424
|
+
| `created_at` | timestamp | When | `2026-07-16T15:00:00Z` |
|
|
425
|
+
|
|
426
|
+
#### `bpm_document` *(new)*
|
|
427
|
+
|
|
428
|
+
Uploaded or generated file for an instance.
|
|
429
|
+
|
|
430
|
+
| Column | Type | Description | Example |
|
|
431
|
+
|--------|------|-------------|---------|
|
|
432
|
+
| `id` | PK | Document id | `70` |
|
|
433
|
+
| `instance_id` | FK | Process instance | `5001` |
|
|
434
|
+
| `document_type_id` | FK | Type | `7` |
|
|
435
|
+
| `tenant_id` | int | Tenant | `674` |
|
|
436
|
+
| `file_name` | varchar | Original name | `host-agreement.pdf` |
|
|
437
|
+
| `s3_key` | varchar | Storage key | `bpm/674/5001/host-agreement.pdf` |
|
|
438
|
+
| `mime_type` | varchar | MIME | `application/pdf` |
|
|
439
|
+
| `status` | varchar | `required` \| `uploaded` \| `approved` \| `rejected` | `uploaded` |
|
|
440
|
+
| `uploaded_by` | varchar null | Actor | `coach@sf.org` |
|
|
441
|
+
|
|
442
|
+
#### `bpm_message` *(← `twf_event`)*
|
|
443
|
+
|
|
444
|
+
Inbound event / signal that starts or resumes a process.
|
|
445
|
+
|
|
446
|
+
| Column | Type | Description | Example |
|
|
447
|
+
|--------|------|-------------|---------|
|
|
448
|
+
| `id` | PK | Message id | `56953` |
|
|
449
|
+
| `action_code` | varchar | Trigger action code (or process key) | `SF NEW COS MESSAGE` |
|
|
450
|
+
| `tenant_id` | int null | Known tenant if resolved | `674` |
|
|
451
|
+
| `business_key` | varchar null | Correlation | `cos-message:8891` |
|
|
452
|
+
| `payload_json` | json | Event body / origin row snapshot | `{ "id": 8891, "referralId": 321, "familyName": "The Smith Family", "familyInitials": "SM" }` |
|
|
453
|
+
| `desc_job_name` | varchar | Caller context (like today) | `CircleOfSupport/createMessage` |
|
|
454
|
+
| `status` | varchar | `pending` \| `processed` \| `failed` | `processed` |
|
|
455
|
+
| `process_instance_id` | FK null | Instance created/signaled | `5001` |
|
|
456
|
+
|
|
457
|
+
#### `bpm_job` *(new)*
|
|
458
|
+
|
|
459
|
+
Async work: timers, HTTP retries, delayed continuations (worker queue rows).
|
|
460
|
+
|
|
461
|
+
| Column | Type | Description | Example |
|
|
462
|
+
|--------|------|-------------|---------|
|
|
463
|
+
| `id` | PK | Job id | `300` |
|
|
464
|
+
| `instance_id` | FK null | Related instance | `5001` |
|
|
465
|
+
| `activity_id` | FK null | Related activity | `80001` |
|
|
466
|
+
| `job_type` | varchar | `TIMER` \| `HTTP` \| `EMAIL` \| `PUSH` \| `CONTINUE` | `PUSH` |
|
|
467
|
+
| `run_at` | timestamp | When eligible | `2026-07-16T14:01:05Z` |
|
|
468
|
+
| `attempts` | int | Retry count | `1` |
|
|
469
|
+
| `max_attempts` | int | Cap | `4` |
|
|
470
|
+
| `payload_json` | json | Worker payload | `{ "actionCode": "SF SEND COS PUSH", "config": { ... } }` |
|
|
471
|
+
| `status` | varchar | `scheduled` \| `running` \| `done` \| `failed` | `done` |
|
|
472
|
+
| `last_error` | text null | Last failure | `null` |
|
|
473
|
+
|
|
474
|
+
#### `bpm_audit_log` *(← `twf_flow_log`)*
|
|
475
|
+
|
|
476
|
+
Immutable trail for support and compliance.
|
|
477
|
+
|
|
478
|
+
| Column | Type | Description | Example |
|
|
479
|
+
|--------|------|-------------|---------|
|
|
480
|
+
| `id` | PK | Log id | `10001` |
|
|
481
|
+
| `instance_id` | FK null | Process instance | `5001` |
|
|
482
|
+
| `tenant_id` | int null | Tenant | `674` |
|
|
483
|
+
| `event_type` | varchar | `INSTANCE_STARTED` \| `TASK_COMPLETED` \| `NODE_FAILED` \| … | `NODE_FAILED` |
|
|
484
|
+
| `message` | text | Human-readable summary | `Push connector timeout` |
|
|
485
|
+
| `details_json` | json | Structured detail / stack excerpt | `{ "activityId": 80001, "error": "ETIMEDOUT" }` |
|
|
486
|
+
| `created_at` | timestamp | When | `2026-07-16T14:02:00Z` |
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## 4. Multi-tenancy (catalog visibility) — short
|
|
491
|
+
|
|
492
|
+
| Scope | Who sees the action/form/template |
|
|
493
|
+
|-------|-----------------------------------|
|
|
494
|
+
| `SYSTEM` | All tenants |
|
|
495
|
+
| `GROUP` | Tenants in linked `bpm_tenant_group` (via `bpm_action_group`) |
|
|
496
|
+
| `TENANT` | Only `owner_tenant_id` |
|
|
497
|
+
|
|
498
|
+
Builder palette = `SYSTEM ∪ GROUP(member) ∪ TENANT(owner)`.
|
|
499
|
+
**Always re-validate on publish and execute** (never trust UI alone).
|
|
500
|
+
Instance/task/document data is always hard-scoped by `tenant_id`.
|
|
501
|
+
|
|
502
|
+
Replace today’s “vendor 55 runs for everyone” with **platform/group templates + tenant clone or subscription**.
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
## Appendix A — Keep / improve from current Flows
|
|
507
|
+
|
|
508
|
+
**Keep:** stable action `code`, action catalog + `schema_json`, node graph, event + SQS worker, template duplicate, instance history, 3-pane builder.
|
|
509
|
+
|
|
510
|
+
**Improve:** human tasks, approvals, forms, documents, gateway UX, case variables, HTTP connectors, immutable versions, observability.
|
|
511
|
+
|
|
512
|
+
Details of the current engine: [FLOWS.md](./FLOWS.md).
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
## Appendix B — Target capabilities (MVP → later)
|
|
517
|
+
|
|
518
|
+
| Capability | Approach |
|
|
519
|
+
|------------|----------|
|
|
520
|
+
| Forms | `bpm_form_*` + User Task node |
|
|
521
|
+
| Approvals | `bpm_approval_policy` + `bpm_task` |
|
|
522
|
+
| Branching | `exclusiveGateway` / `parallelGateway` + `bpm_edge.condition_expr` + `bpm_token` |
|
|
523
|
+
| Documents | `bpm_document_type` + `bpm_document` + S3 |
|
|
524
|
+
| External APIs | Action types `HTTP_REQUEST`, `WEBHOOK_WAIT`, connectors |
|
|
525
|
+
| Automations | Same engine; category=`automation`; service tasks only |
|
|
526
|
+
|
|
527
|
+
---
|
|
528
|
+
|
|
529
|
+
## Appendix C — Stack & migration
|
|
530
|
+
|
|
531
|
+
| Layer | Choice |
|
|
532
|
+
|-------|--------|
|
|
533
|
+
| API + engine v1 | **NestJS** modular monolith |
|
|
534
|
+
| Workers | NestJS + SQS/BullMQ |
|
|
535
|
+
| UI | React (evolve Harmony builder + task inbox) |
|
|
536
|
+
| DB | PostgreSQL preferred |
|
|
537
|
+
| Later | Optional Go workers if metrics demand |
|
|
538
|
+
|
|
539
|
+
**Migration:** keep `twf_*` live → adapter from `EventHq.add` → migrate SF processes first → retire legacy write path when ready.
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
## Appendix D — Open decisions
|
|
544
|
+
|
|
545
|
+
1. Expression language: JSONLogic vs CEL vs JMESPath
|
|
546
|
+
2. Form tech: RJSF vs Formily vs custom
|
|
547
|
+
3. Postgres vs MySQL for the new DB
|
|
548
|
+
4. Inbox inside Harmony vs separate app
|
|
549
|
+
5. BPMN XML export in v1 or JSON-only
|
|
550
|
+
6. Tenant groups: reuse `business_type` vs dedicated packs
|
|
551
|
+
7. Global automations: subscription (recommended) vs fan-out listener
|
|
552
|
+
|
|
553
|
+
---
|
|
554
|
+
|
|
555
|
+
## Appendix E — Example process (Safe Families)
|
|
556
|
+
|
|
557
|
+
```text
|
|
558
|
+
Start (SF NEW COS MESSAGE / SF NEW POSTED NEED)
|
|
559
|
+
→ Service Task: notify (push)
|
|
560
|
+
→ [BPM] User/Approval Task (optional)
|
|
561
|
+
→ Exclusive Gateway (approved / rejected)
|
|
562
|
+
→ Service Task: decision notification
|
|
563
|
+
→ End
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
Same trigger model as today; richer graph when human steps are needed.
|
package/README.md
CHANGED
|
@@ -1 +1,437 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Tempo Flows (Workflow Engine)
|
|
2
|
+
|
|
3
|
+
This document describes how automation **flows** work in Tempo: database structure, runtime pipeline, and how to trigger or assemble them.
|
|
4
|
+
|
|
5
|
+
Flows are a **node-graph automation engine**. Definitions and runtime state live in MySQL tables prefixed `twf_*`. Core entities and graph execution come from **`@pxp-nd/flow`**. Tempo adds business orchestration via `EventHq`, `FlowHQ`, and an SQS worker.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## High-level architecture
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
Business code (controllers / PHP jobs)
|
|
13
|
+
│
|
|
14
|
+
▼
|
|
15
|
+
EventHq.add({ actionId, descJobName, dataId })
|
|
16
|
+
│
|
|
17
|
+
├─► twf_event (status: pending)
|
|
18
|
+
└─► SQS FIFO (FLOW_QUEUE_URL)
|
|
19
|
+
│
|
|
20
|
+
▼
|
|
21
|
+
worker.ts → EventHq.processSingleEvent({ eventId })
|
|
22
|
+
│
|
|
23
|
+
├─► Find active twf_node rows for actionId
|
|
24
|
+
├─► Query origin view (origin_name + origin_key = dataId)
|
|
25
|
+
├─► Vendor match + event_config conditions
|
|
26
|
+
├─► Create twf_flow_instance
|
|
27
|
+
└─► NodeInstanceController.RecursiveInstance() walks the DAG
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Key Tempo files
|
|
31
|
+
|
|
32
|
+
| File | Role |
|
|
33
|
+
|------|------|
|
|
34
|
+
| `src/modules/hq-nd/controllers/EventHq.ts` | Enqueue events to SQS; process a single event into flow instances |
|
|
35
|
+
| `src/modules/hq-nd/controllers/Flow.ts` (`FlowHQ`) | Duplicate templates, chained flows, funnel lists/statistics |
|
|
36
|
+
| `src/worker.ts` | SQS consumer (started when `INIT_FLOW_WORKER=Y`) |
|
|
37
|
+
| `src/config.ts` / `ormconfig.js` | Registers `flow-nd` module and merges flow entities |
|
|
38
|
+
|
|
39
|
+
Entities such as `Flow`, `Node`, `Action`, `Event`, `FlowInstance`, `NodeInstance` are **not** redefined in Tempo; they come from `@pxp-nd/flow`.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## How execution works (summary)
|
|
44
|
+
|
|
45
|
+
1. **Fire an event**
|
|
46
|
+
Application code looks up `twf_action` by stable `code`, then calls `EventHq.add` with:
|
|
47
|
+
- `actionId` — PK of the action
|
|
48
|
+
- `dataId` — PK value in the action’s origin view (`origin_key`)
|
|
49
|
+
- `descJobName` — caller context for debugging
|
|
50
|
+
|
|
51
|
+
2. **Enqueue**
|
|
52
|
+
`EventHq.add` inserts a `twf_event` (`status = pending`) and sends a message to SQS containing `eventId`.
|
|
53
|
+
|
|
54
|
+
3. **Worker**
|
|
55
|
+
`pollQueue()` in `src/worker.ts` receives the message, deletes it from the queue, then calls `processSingleEvent` with retries (1s / 5s / 10s / 20s). Failures after retries go to Slack (`SLACK_TEMPO_ERROR`).
|
|
56
|
+
|
|
57
|
+
4. **Match flows**
|
|
58
|
+
For every active `twf_node` with that `action_id`:
|
|
59
|
+
- Load one row: `SELECT * FROM {origin_name} WHERE {origin_key} = {dataId}`
|
|
60
|
+
- Load parent `twf_flow` — must be `is_active = 1` and **`status = 'on'`**
|
|
61
|
+
- Vendor filter: `flow.vendor_id === view.vendor_id` **OR** `flow.vendor_id === 55` (global Qorus flows)
|
|
62
|
+
- Optional `event_config` filters on the action/node
|
|
63
|
+
|
|
64
|
+
5. **Run the graph**
|
|
65
|
+
Create `twf_flow_instance`, then walk `twf_node` / `twf_node_connection` via `RecursiveInstance()`. Each step creates `twf_node_instance` rows. Errors are stored in `twf_flow_log`.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Database ER diagram
|
|
70
|
+
|
|
71
|
+
```mermaid
|
|
72
|
+
erDiagram
|
|
73
|
+
twf_action_type ||--o{ twf_action : "categorizes"
|
|
74
|
+
twf_action ||--o{ twf_node : "used by"
|
|
75
|
+
twf_action ||--o{ twf_event : "triggered as"
|
|
76
|
+
twf_action ||--o{ twf_flow_instance : "started by"
|
|
77
|
+
twf_flow ||--o{ twf_node : "contains"
|
|
78
|
+
twf_flow ||--o{ twf_flow_instance : "instantiates"
|
|
79
|
+
twf_node ||--o{ twf_node_connection : "master"
|
|
80
|
+
twf_node ||--o{ twf_node_connection : "child"
|
|
81
|
+
twf_node ||--o{ twf_node_instance : "executes as"
|
|
82
|
+
twf_flow_instance ||--o{ twf_node_instance : "has steps"
|
|
83
|
+
twf_origin_name ||--o{ twf_field_map : "maps fields"
|
|
84
|
+
|
|
85
|
+
twf_action_type {
|
|
86
|
+
int action_type_id PK
|
|
87
|
+
varchar name
|
|
88
|
+
varchar schema_json
|
|
89
|
+
varchar is_start
|
|
90
|
+
varchar is_delay
|
|
91
|
+
varchar controller
|
|
92
|
+
varchar on_duplicate
|
|
93
|
+
tinyint is_active
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
twf_action {
|
|
97
|
+
int action_id PK
|
|
98
|
+
int action_type_id FK
|
|
99
|
+
varchar code "stable contract"
|
|
100
|
+
varchar name
|
|
101
|
+
varchar origin_name "DB view"
|
|
102
|
+
varchar origin_key "PK column in view"
|
|
103
|
+
varchar controller_function
|
|
104
|
+
varchar schema_json
|
|
105
|
+
varchar event_config
|
|
106
|
+
varchar config_json_template
|
|
107
|
+
tinyint is_active
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
twf_flow {
|
|
111
|
+
int flow_id PK
|
|
112
|
+
int vendor_id "0/55 template or vendor"
|
|
113
|
+
varchar code
|
|
114
|
+
varchar name
|
|
115
|
+
varchar type "template|custom"
|
|
116
|
+
varchar template_type "funnel|flow|null"
|
|
117
|
+
varchar status "must be on to run"
|
|
118
|
+
varchar enabled
|
|
119
|
+
tinyint is_active
|
|
120
|
+
varchar publish
|
|
121
|
+
varchar flow_type
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
twf_node {
|
|
125
|
+
int node_id PK
|
|
126
|
+
int flow_id FK
|
|
127
|
+
int action_id FK
|
|
128
|
+
varchar is_init "Y = entry/trigger"
|
|
129
|
+
varchar is_end
|
|
130
|
+
varchar action_config_json "runtime values"
|
|
131
|
+
varchar approval_config
|
|
132
|
+
tinyint is_active
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
twf_node_connection {
|
|
136
|
+
int node_connection_id PK
|
|
137
|
+
int node_id_master "NULL = init edge"
|
|
138
|
+
int node_id_child FK
|
|
139
|
+
varchar condition
|
|
140
|
+
tinyint is_active
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
twf_event {
|
|
144
|
+
int event_id PK
|
|
145
|
+
int action_id FK
|
|
146
|
+
int data_id "origin PK value"
|
|
147
|
+
varchar desc_job_name
|
|
148
|
+
varchar status "pending|processed"
|
|
149
|
+
tinyint is_active
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
twf_flow_instance {
|
|
153
|
+
int flow_instance_id PK
|
|
154
|
+
int flow_id FK
|
|
155
|
+
int data_id
|
|
156
|
+
int action_id
|
|
157
|
+
varchar origin_name
|
|
158
|
+
varchar origin_key
|
|
159
|
+
varchar status
|
|
160
|
+
varchar flag_name
|
|
161
|
+
int flag_value
|
|
162
|
+
tinyint is_active
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
twf_node_instance {
|
|
166
|
+
int node_instance_id PK
|
|
167
|
+
int node_id FK
|
|
168
|
+
int flow_instance_id FK
|
|
169
|
+
timestamp run_time
|
|
170
|
+
timestamp schedule
|
|
171
|
+
varchar status
|
|
172
|
+
json extra_args
|
|
173
|
+
tinyint is_active
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
twf_origin_name {
|
|
177
|
+
int origin_name_id PK
|
|
178
|
+
varchar name "view name"
|
|
179
|
+
varchar originKey
|
|
180
|
+
tinyint is_active
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
twf_field_map {
|
|
184
|
+
int field_map_id PK
|
|
185
|
+
int origin_name_id FK
|
|
186
|
+
varchar name
|
|
187
|
+
varchar alias
|
|
188
|
+
varchar type
|
|
189
|
+
tinyint is_active
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
twf_flow_log {
|
|
193
|
+
int flow_log_id PK
|
|
194
|
+
varchar error_custom_message
|
|
195
|
+
varchar error_message
|
|
196
|
+
text stack_trace
|
|
197
|
+
tinyint is_active
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
> Note: Some FK relationships are logical (enforced in application / TypeORM) rather than hard MySQL foreign keys on every column.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Runtime pipeline diagram
|
|
206
|
+
|
|
207
|
+
```mermaid
|
|
208
|
+
flowchart LR
|
|
209
|
+
A[Business code] -->|Action.findOne by code| B[EventHq.add]
|
|
210
|
+
B --> C[(twf_event pending)]
|
|
211
|
+
B --> D[SQS FLOW_QUEUE_URL]
|
|
212
|
+
D --> E[worker.ts pollQueue]
|
|
213
|
+
E --> F[EventHq.processSingleEvent]
|
|
214
|
+
F --> G{Active nodes for actionId}
|
|
215
|
+
G --> H[Query origin view by dataId]
|
|
216
|
+
H --> I{flow status=on<br/>vendor match or vendor 55}
|
|
217
|
+
I -->|yes| J[(twf_flow_instance)]
|
|
218
|
+
J --> K[RecursiveInstance DAG walk]
|
|
219
|
+
K --> L[(twf_node_instance)]
|
|
220
|
+
I -->|no| M[Skip node]
|
|
221
|
+
F -.->|errors| N[(twf_flow_log)]
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Table reference
|
|
227
|
+
|
|
228
|
+
| Table | Role |
|
|
229
|
+
|-------|------|
|
|
230
|
+
| `twf_action_type` | Category of step (EVENT, EMAIL, DELAY, PAGE, CHAINED FLOW, CONTROLLER EXECUTION, …) |
|
|
231
|
+
| `twf_action` | Reusable action / event definition; stable API via `code` |
|
|
232
|
+
| `twf_flow` | Flow definition (template or vendor custom instance) |
|
|
233
|
+
| `twf_node` | One step in a flow; points at one action; holds `action_config_json` |
|
|
234
|
+
| `twf_node_connection` | Directed edges (DAG). Init connection uses `node_id_master = NULL` |
|
|
235
|
+
| `twf_event` | Async event queue row |
|
|
236
|
+
| `twf_flow_instance` | One execution of a flow for a given `dataId` |
|
|
237
|
+
| `twf_node_instance` | Per-step execution record |
|
|
238
|
+
| `twf_origin_name` | Registry of DB views used as event data sources |
|
|
239
|
+
| `twf_field_map` | Column aliases for Harmony builder / templating |
|
|
240
|
+
| `twf_flow_log` | Execution / processing errors |
|
|
241
|
+
|
|
242
|
+
### Important `twf_flow` fields
|
|
243
|
+
|
|
244
|
+
| Field | Meaning |
|
|
245
|
+
|-------|---------|
|
|
246
|
+
| `type` | `template` (blueprint) or `custom` (vendor instance) |
|
|
247
|
+
| `template_type` | `funnel`, `flow`, or null |
|
|
248
|
+
| `status` | Must be **`on`** for `EventHq` to execute the flow |
|
|
249
|
+
| `vendor_id` | `0` / `55` for global templates; vendor id for instances. **Vendor 55** matches any vendor’s events |
|
|
250
|
+
| `code` | Optional stable identifier (e.g. `NEW_EMAIL_LIST`) |
|
|
251
|
+
|
|
252
|
+
### Important `twf_action` fields
|
|
253
|
+
|
|
254
|
+
| Field | Meaning |
|
|
255
|
+
|-------|---------|
|
|
256
|
+
| `code` | Unique string — always resolve with `Action.findOne({ code })` |
|
|
257
|
+
| `origin_name` / `origin_key` | View + PK used when resolving `dataId` |
|
|
258
|
+
| `schema_json` | UI form for Harmony flow builder |
|
|
259
|
+
| `event_config` | Optional filters: `{ "filters": [{ "originField", "nodeField" }] }` |
|
|
260
|
+
| `controller_function` | Tempo controller path for CONTROLLER EXECUTION (e.g. `hq-nd/BusinessTypeSF/sendApprovalNotification`) |
|
|
261
|
+
|
|
262
|
+
### Common `action_type_id` values (active)
|
|
263
|
+
|
|
264
|
+
| ID | Name | Notes |
|
|
265
|
+
|----|------|--------|
|
|
266
|
+
| 9 | EVENT | Trigger / listener node |
|
|
267
|
+
| 10 | EMAIL NOTIFICATION | Send email |
|
|
268
|
+
| 11 | DELAY | Wait step |
|
|
269
|
+
| 16 | PAGE | Opt-in / bridge pages |
|
|
270
|
+
| 24 | CHAINED FLOW | Invoke another flow |
|
|
271
|
+
| 26 | PUSH NOTIFICATION | Mobile push |
|
|
272
|
+
| 28 | CONTROLLER EXECUTION | Call a Tempo controller method |
|
|
273
|
+
|
|
274
|
+
`on_duplicate` on the action type drives post-processing when duplicating a flow (`duplicateEmailTemplate`, `duplicatePageTemplate`, `duplicateChainedFlow`).
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## JSON configuration layers
|
|
279
|
+
|
|
280
|
+
1. **`twf_action_type.schema_json`** — shared UI schema for the type
|
|
281
|
+
2. **`twf_action.schema_json`** — per-action UI overrides
|
|
282
|
+
3. **`twf_node.action_config_json`** — **actual values at runtime** for that node
|
|
283
|
+
|
|
284
|
+
Examples of `action_config_json`:
|
|
285
|
+
|
|
286
|
+
| Type | Typical keys |
|
|
287
|
+
|------|----------------|
|
|
288
|
+
| PAGE | `templateBuilderId`, `emailListId`, `smsListId`, `title` |
|
|
289
|
+
| EMAIL | `to`, `subject`, `templateId` |
|
|
290
|
+
| DELAY | `quantity`, `type` (`hours` / `minutes` / `days`) |
|
|
291
|
+
| CHAINED / EMAIL_JOURNEY | `flowId` |
|
|
292
|
+
| CONTROLLER / push (SF) | `subject`, `message`, `mobileActionId`, `notificationType` |
|
|
293
|
+
|
|
294
|
+
Templates may use `{{ FIELD }}` placeholders resolved from the origin view row (`config_json_template` / node config).
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## Triggering a flow from TypeScript
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
import { Action, Event } from '@pxp-nd/flow';
|
|
302
|
+
import EventHq from './EventHq';
|
|
303
|
+
|
|
304
|
+
const action = await Action.findOne({ code: 'ADD EMAIL LIST ADDRESS' });
|
|
305
|
+
if (action) {
|
|
306
|
+
const eventInstance = new EventHq('hq-nd/EventHq', Event);
|
|
307
|
+
await eventInstance.add({
|
|
308
|
+
actionId: action.actionId,
|
|
309
|
+
descJobName: 'formLegato', // caller context
|
|
310
|
+
dataId: resInsert.emailListAddrId, // PK in action.origin_name
|
|
311
|
+
}, manager);
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Rules:
|
|
316
|
+
|
|
317
|
+
- Prefer **`EventHq.add()`** in Tempo (enqueues SQS). Do not bypass the queue for production event firing.
|
|
318
|
+
- Never hardcode `action_id`; always look up by `code`.
|
|
319
|
+
- `dataId` must exist in the action’s origin view.
|
|
320
|
+
- Flow must be `status = 'on'` and `is_active = 1`.
|
|
321
|
+
|
|
322
|
+
Events may also be fired from legacy PHP (e.g. billing jobs calling `hq-nd/EventHq/add`).
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Assembling / creating flows
|
|
327
|
+
|
|
328
|
+
### A. Duplicate a template (preferred for vendor instances)
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
const flowHq = new FlowHQ('hq-nd/FlowHQ');
|
|
332
|
+
const result = await flowHq.duplicateFlow(
|
|
333
|
+
{ flowId: templateFlowId, vendorId, name: 'Bridge Funnel' },
|
|
334
|
+
manager
|
|
335
|
+
);
|
|
336
|
+
// Activate if needed:
|
|
337
|
+
await manager.createQueryBuilder().update('twf_flow')
|
|
338
|
+
.set({ status: 'on' })
|
|
339
|
+
.where('flowId = :flowId', { flowId })
|
|
340
|
+
.execute();
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Template IDs often come from env:
|
|
344
|
+
|
|
345
|
+
- `TEMPLATE_BRIDGE_FUNNEL_FLOW_ID` (default `149`)
|
|
346
|
+
- `TEMPLATE_QORD_BRIDGE_FUNNEL_FLOW_ID` (default `3419`)
|
|
347
|
+
|
|
348
|
+
### B. Patch init node after duplicate
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
await manager.createQueryBuilder().update('twf_node')
|
|
352
|
+
.set({ actionConfigJson: JSON.stringify({ emailListId }) })
|
|
353
|
+
.where('flowId = :flowId AND isInit = :isInit', { flowId, isInit: 'Y' })
|
|
354
|
+
.execute();
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### C. Chained funnel → email journey
|
|
358
|
+
|
|
359
|
+
Use `FlowHQ.addChainedFlow()` (duplicates child flow, copies `emailListId`, activates).
|
|
360
|
+
|
|
361
|
+
### D. SQL registration
|
|
362
|
+
|
|
363
|
+
Typical order:
|
|
364
|
+
|
|
365
|
+
1. Optional `twf_action_type`
|
|
366
|
+
2. `twf_action` (EVENT + step actions) with `WHERE NOT EXISTS (... code ...)`
|
|
367
|
+
3. If new origin: `twf_origin_name` + `twf_field_map`
|
|
368
|
+
4. Optional full graph: `twf_flow` → init `twf_node` → connections → step nodes
|
|
369
|
+
5. Register in `src/modules/hq-nd/database/jaime-scripts.ts` as `JRR-HQ-[STORY]-[SEQ]`
|
|
370
|
+
|
|
371
|
+
Modern SF-style DML often registers **actions only** and lets the Harmony UI assemble the graph.
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## Vendor matching (important)
|
|
376
|
+
|
|
377
|
+
When processing an event, Tempo runs a flow if:
|
|
378
|
+
|
|
379
|
+
```text
|
|
380
|
+
flow.vendorId === viewVendorId OR flow.vendorId === 55
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
So:
|
|
384
|
+
|
|
385
|
+
- A **vendor-specific** flow only runs for that vendor’s data.
|
|
386
|
+
- A **vendor 55 (Qorus)** flow runs for **any** vendor that fires the matching event (global notifications).
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## Environment variables
|
|
391
|
+
|
|
392
|
+
| Variable | Purpose |
|
|
393
|
+
|----------|---------|
|
|
394
|
+
| `FLOW_QUEUE_URL` | SQS FIFO queue for async events |
|
|
395
|
+
| `INIT_FLOW_WORKER` | `Y` starts `pollQueue()` from `server.ts` |
|
|
396
|
+
| `TEMPLATE_BRIDGE_FUNNEL_FLOW_ID` | Default bridge funnel template |
|
|
397
|
+
| `TEMPLATE_QORD_BRIDGE_FUNNEL_FLOW_ID` | Qord bridge funnel template |
|
|
398
|
+
| `SLACK_TEMPO_ERROR` | Channel for worker failures after retries |
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## Naming conventions
|
|
403
|
+
|
|
404
|
+
- Action `code`: UPPER CASE with spaces — e.g. `NEW POSTED NEED`, `BILLING NOT PROCESSED`, `SF SEND COS PUSH`
|
|
405
|
+
- Funnel templates: `type = 'template'`, `template_type = 'funnel'`
|
|
406
|
+
- Email / automation templates: `type = 'template'`, `template_type = 'flow'` (or null)
|
|
407
|
+
- Vendor copies after `duplicateFlow`: `type = 'custom'`
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## Do / Don’t
|
|
412
|
+
|
|
413
|
+
**Do**
|
|
414
|
+
|
|
415
|
+
- Find actions by `code`
|
|
416
|
+
- Duplicate templates for vendor flows; keep them `status = 'on'` when they should run
|
|
417
|
+
- Register new origins in `twf_origin_name` (+ field maps) before using them in actions
|
|
418
|
+
- Use `EventHq.add` so processing goes through SQS
|
|
419
|
+
|
|
420
|
+
**Don’t**
|
|
421
|
+
|
|
422
|
+
- Hardcode `action_id`
|
|
423
|
+
- Auto-sync `twf_*` schema (`synchronize` stays false)
|
|
424
|
+
- Skip SQS for production event firing
|
|
425
|
+
- Hand-build full vendor graphs in TypeScript when a template + `duplicateFlow` exists
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## Controllers that commonly fire events
|
|
430
|
+
|
|
431
|
+
Examples in Tempo: `Site`, `Vendor`, `Member`, `Package`, `MemberAccounts`, `EmailList`, `PageTemplate`, `Extra`, `CloudFrontFlow`, mobile `Subscriptions`, `helpers/Site.ts`. Legacy Harmony PHP jobs may also call `EventHq/add` (e.g. billing).
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## Related Cursor rules
|
|
436
|
+
|
|
437
|
+
Project conventions for flows are also summarized in `.cursorrules` under **Flows (Workflow Engine)**. Keep this document and those rules aligned when changing the event pipeline or `twf_*` contracts.
|
package/dist/controllers/Flow.js
CHANGED
|
@@ -719,7 +719,7 @@ let Flow = class Flow extends core_1.Controller {
|
|
|
719
719
|
//valid controllerValidation
|
|
720
720
|
const { action: { actionType } } = node;
|
|
721
721
|
const { validationController } = actionType;
|
|
722
|
-
const nodeController = new Node_2.default('flow-nd');
|
|
722
|
+
const nodeController = new Node_2.default('flow-nd', Node_1.default);
|
|
723
723
|
await nodeController.executeValidationController({ validationController, actionConfigJson: values, showException: true });
|
|
724
724
|
//end valid controller
|
|
725
725
|
const missingRequired = [];
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Last modified :
|
|
14
14
|
* ISSUE DATE NAME DESCRIPTION
|
|
15
15
|
* 9141239986 16-May-2025 Mercedes Zambrana Save flagName and FlagValue
|
|
16
|
+
* EF-23 08-Oct-2026 Favio Figueroa GET_LOCK instead of flag, skipped=200, axios timeout
|
|
16
17
|
*/
|
|
17
18
|
import { EntityManager } from 'typeorm';
|
|
18
19
|
import { Controller } from '@pxp-nd/core';
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Last modified :
|
|
15
15
|
* ISSUE DATE NAME DESCRIPTION
|
|
16
16
|
* 9141239986 16-May-2025 Mercedes Zambrana Save flagName and FlagValue
|
|
17
|
+
* EF-23 08-Oct-2026 Favio Figueroa GET_LOCK instead of flag, skipped=200, axios timeout
|
|
17
18
|
*/
|
|
18
19
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
19
20
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
@@ -39,16 +40,11 @@ const Event_1 = __importDefault(require("../entity/Event"));
|
|
|
39
40
|
const Flow_1 = __importDefault(require("../entity/Flow"));
|
|
40
41
|
const Action_1 = __importDefault(require("../entity/Action"));
|
|
41
42
|
const FlowLog_1 = __importDefault(require("../entity/FlowLog"));
|
|
43
|
+
const dbLock_1 = require("../helpers/dbLock");
|
|
42
44
|
class FlowInstance extends core_1.Controller {
|
|
43
45
|
async processPending(params, manager) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (flag.value == 'true') {
|
|
47
|
-
throw new core_1.PxpError(400, 'Already processing pending flows');
|
|
48
|
-
}
|
|
49
|
-
flag.value = 'true';
|
|
50
|
-
let flowInstanceIdProcessing = -1;
|
|
51
|
-
await (0, typeorm_1.getManager)().save(flag);
|
|
46
|
+
let flowInstanceIdProcessing = -1;
|
|
47
|
+
const { acquired } = await (0, dbLock_1.withDbLock)('wf_process_pending', async () => {
|
|
52
48
|
try {
|
|
53
49
|
await this.eventProcesss(manager);
|
|
54
50
|
const maxFlows = await common_1.GlobalData.findOne({ data: 'wf_max_concurrent_flows_process' });
|
|
@@ -69,17 +65,17 @@ class FlowInstance extends core_1.Controller {
|
|
|
69
65
|
}
|
|
70
66
|
}
|
|
71
67
|
catch (error) {
|
|
72
|
-
|
|
73
|
-
await (0, typeorm_1.getManager)().save(flag);
|
|
68
|
+
// The lock is released by withDbLock's finally block
|
|
74
69
|
const log = new FlowLog_1.default();
|
|
75
70
|
log.errorCustomMessage = `Error executing ${flowInstanceIdProcessing} in FlowInstance-> processPending `;
|
|
76
71
|
log.errorMessage = error.message;
|
|
77
72
|
log.stackTrace = error.stack || '';
|
|
78
|
-
(0, typeorm_1.getManager)().save(log);
|
|
73
|
+
await (0, typeorm_1.getManager)().save(log).catch(e => console.error('[flow] FlowLog save failed', e.message));
|
|
79
74
|
throw new core_1.PxpError(400, 'Error processing flow: ' + flowInstanceIdProcessing);
|
|
80
75
|
}
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
});
|
|
77
|
+
if (!acquired) {
|
|
78
|
+
return { success: true, skipped: true, reason: 'already running' };
|
|
83
79
|
}
|
|
84
80
|
return { success: true };
|
|
85
81
|
}
|
|
@@ -115,7 +111,7 @@ class FlowInstance extends core_1.Controller {
|
|
|
115
111
|
log.errorCustomMessage = `Error processing event ${newEvent.eventId} in FlowInstance-> eventProcesss `;
|
|
116
112
|
log.errorMessage = error.message;
|
|
117
113
|
log.stackTrace = error.stack || '';
|
|
118
|
-
(0, typeorm_1.getManager)().save(log);
|
|
114
|
+
await (0, typeorm_1.getManager)().save(log).catch(e => console.error('[flow] FlowLog save failed', e.message));
|
|
119
115
|
}
|
|
120
116
|
}
|
|
121
117
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Last modified :
|
|
14
14
|
* ISSUE DATE AUTHOR DESCRIPTION
|
|
15
15
|
* 25-Apr-2025 8914754251 Mercedes Zambrana Validate and use node?.action?.controllerFunction
|
|
16
|
+
* 08-Oct-2026 EF-23 Favio Figueroa GET_LOCK instead of flag, skipped=200, axios timeout
|
|
16
17
|
*/
|
|
17
18
|
import { EntityManager } from "typeorm";
|
|
18
19
|
import { Controller } from "@pxp-nd/core";
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Last modified :
|
|
15
15
|
* ISSUE DATE AUTHOR DESCRIPTION
|
|
16
16
|
* 25-Apr-2025 8914754251 Mercedes Zambrana Validate and use node?.action?.controllerFunction
|
|
17
|
+
* 08-Oct-2026 EF-23 Favio Figueroa GET_LOCK instead of flag, skipped=200, axios timeout
|
|
17
18
|
*/
|
|
18
19
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
19
20
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
@@ -40,6 +41,7 @@ const ActionType_1 = __importDefault(require("../entity/ActionType"));
|
|
|
40
41
|
const Action_1 = __importDefault(require("../entity/Action"));
|
|
41
42
|
const lodash_1 = __importDefault(require("lodash"));
|
|
42
43
|
const FlowInstance_1 = __importDefault(require("../entity/FlowInstance"));
|
|
44
|
+
const dbLock_1 = require("../helpers/dbLock");
|
|
43
45
|
//TODO replace this flow-nd need tobe independent library
|
|
44
46
|
const common_1 = require("@pxp-nd/common");
|
|
45
47
|
let NodeInstance = class NodeInstance extends core_1.Controller {
|
|
@@ -101,6 +103,7 @@ let NodeInstance = class NodeInstance extends core_1.Controller {
|
|
|
101
103
|
"Content-Type": "application/json",
|
|
102
104
|
},
|
|
103
105
|
data: mergeJson,
|
|
106
|
+
timeout: Number(process.env.FLOW_ACTION_TIMEOUT_MS || 30000),
|
|
104
107
|
};
|
|
105
108
|
const resControllerAxios = await (0, core_1.__)((0, axios_1.default)(config));
|
|
106
109
|
if ('data' in resControllerAxios && resControllerAxios.data) {
|
|
@@ -120,6 +123,7 @@ let NodeInstance = class NodeInstance extends core_1.Controller {
|
|
|
120
123
|
"Content-Type": "application/json",
|
|
121
124
|
},
|
|
122
125
|
data: mergeJson,
|
|
126
|
+
timeout: Number(process.env.FLOW_ACTION_TIMEOUT_MS || 30000),
|
|
123
127
|
};
|
|
124
128
|
const resControllerAxios = await (0, core_1.__)((0, axios_1.default)(config));
|
|
125
129
|
if ('data' in resControllerAxios && resControllerAxios.data) {
|
|
@@ -152,21 +156,13 @@ let NodeInstance = class NodeInstance extends core_1.Controller {
|
|
|
152
156
|
log.errorCustomMessage = `Error executing ${flowInstance.flowInstanceId} in NodeInstance-> RecursiveInstance `;
|
|
153
157
|
log.errorMessage = error.message;
|
|
154
158
|
log.stackTrace = error.stack || '';
|
|
155
|
-
(0, typeorm_1.getManager)().save(log);
|
|
159
|
+
await (0, typeorm_1.getManager)().save(log).catch(e => console.error('[flow] FlowLog save failed', e.message));
|
|
156
160
|
}
|
|
157
161
|
return {};
|
|
158
162
|
}
|
|
159
163
|
async ProcessDelay(params, manager) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
});
|
|
163
|
-
if (flag) {
|
|
164
|
-
if (flag.value == "true") {
|
|
165
|
-
throw new core_1.PxpError(400, "Already processing pending flows");
|
|
166
|
-
}
|
|
167
|
-
flag.value = "true";
|
|
168
|
-
await (0, typeorm_1.getManager)().save(flag);
|
|
169
|
-
let nodeInstanceIdProcessing = -1;
|
|
164
|
+
let nodeInstanceIdProcessing = -1;
|
|
165
|
+
const { acquired } = await (0, dbLock_1.withDbLock)("wf_process_delay", async () => {
|
|
170
166
|
try {
|
|
171
167
|
const maxNodes = await common_1.GlobalData.findOne({
|
|
172
168
|
data: "wf_max_concurrent_delay_process",
|
|
@@ -216,12 +212,12 @@ let NodeInstance = class NodeInstance extends core_1.Controller {
|
|
|
216
212
|
}
|
|
217
213
|
}
|
|
218
214
|
catch (error) {
|
|
219
|
-
|
|
220
|
-
await (0, typeorm_1.getManager)().save(flag);
|
|
215
|
+
// The lock is released by withDbLock's finally block
|
|
221
216
|
throw new core_1.PxpError(400, "Error processing node: " + nodeInstanceIdProcessing);
|
|
222
217
|
}
|
|
223
|
-
|
|
224
|
-
|
|
218
|
+
});
|
|
219
|
+
if (!acquired) {
|
|
220
|
+
return { success: true, skipped: true, reason: "already running" };
|
|
225
221
|
}
|
|
226
222
|
return { success: true };
|
|
227
223
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.withDbLock = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Named MySQL lock helper (GET_LOCK / RELEASE_LOCK).
|
|
6
|
+
*
|
|
7
|
+
* GET_LOCK is bound to the MySQL session, so the lock is taken and released on the
|
|
8
|
+
* same dedicated QueryRunner. Never use a transaction manager or getManager() here:
|
|
9
|
+
* those may run on a different pooled connection.
|
|
10
|
+
*
|
|
11
|
+
* ISSUE DATE AUTHOR DESCRIPTION
|
|
12
|
+
* EF-23 08-Oct-2026 Favio Figueroa GET_LOCK instead of flag, skipped=200, axios timeout
|
|
13
|
+
*/
|
|
14
|
+
const typeorm_1 = require("typeorm");
|
|
15
|
+
async function withDbLock(name, fn) {
|
|
16
|
+
var _a, _b, _c, _d;
|
|
17
|
+
const qr = (0, typeorm_1.getConnection)(process.env.DB_WRITE_CONNECTION_NAME).createQueryRunner();
|
|
18
|
+
await qr.connect();
|
|
19
|
+
let got = 0;
|
|
20
|
+
try {
|
|
21
|
+
// GET_LOCK names are server-wide; prefix with the schema so environments sharing a MySQL instance do not block each other
|
|
22
|
+
const rows = await qr.query("SELECT GET_LOCK(CONCAT(DATABASE(), ':', ?), 0) AS got", [name]);
|
|
23
|
+
got = Number((_b = (_a = rows === null || rows === void 0 ? void 0 : rows[0]) === null || _a === void 0 ? void 0 : _a.got) !== null && _b !== void 0 ? _b : 0);
|
|
24
|
+
if (got !== 1)
|
|
25
|
+
return { acquired: false };
|
|
26
|
+
const result = await fn();
|
|
27
|
+
return { acquired: true, result };
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
if (got === 1) {
|
|
31
|
+
const rel = await qr
|
|
32
|
+
.query("SELECT RELEASE_LOCK(CONCAT(DATABASE(), ':', ?)) AS rel", [name])
|
|
33
|
+
.catch(() => [{ rel: 0 }]);
|
|
34
|
+
if (Number((_d = (_c = rel === null || rel === void 0 ? void 0 : rel[0]) === null || _c === void 0 ? void 0 : _c.rel) !== null && _d !== void 0 ? _d : 0) !== 1) {
|
|
35
|
+
// Lock could not be released on this session: drop the physical connection
|
|
36
|
+
// so MySQL frees the lock instead of returning it to the pool still held.
|
|
37
|
+
const c = qr.databaseConnection;
|
|
38
|
+
if (c === null || c === void 0 ? void 0 : c.destroy)
|
|
39
|
+
c.destroy();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await qr.release().catch(() => { });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
exports.withDbLock = withDbLock;
|