@hotmeshio/hotmesh 0.21.1 → 0.22.0
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/README.md +12 -129
- package/build/modules/utils.js +3 -0
- package/build/package.json +2 -1
- package/build/services/activities/hook.d.ts +178 -58
- package/build/services/activities/hook.js +244 -58
- package/build/services/activities/trigger.js +5 -1
- package/build/services/durable/client.d.ts +238 -67
- package/build/services/durable/client.js +307 -131
- package/build/services/durable/index.d.ts +0 -2
- package/build/services/durable/schemas/factory.js +40 -0
- package/build/services/durable/worker.js +5 -28
- package/build/services/durable/workflow/condition.d.ts +69 -37
- package/build/services/durable/workflow/condition.js +70 -39
- package/build/services/hotmesh/index.d.ts +31 -4
- package/build/services/hotmesh/index.js +31 -4
- package/build/services/store/index.d.ts +1 -1
- package/build/services/store/providers/postgres/kvsql.d.ts +1 -1
- package/build/services/store/providers/postgres/kvtables.js +83 -122
- package/build/services/store/providers/postgres/kvtypes/hash/basic.d.ts +1 -1
- package/build/services/store/providers/postgres/kvtypes/hash/basic.js +8 -8
- package/build/services/store/providers/postgres/kvtypes/hash/index.d.ts +1 -1
- package/build/services/store/providers/postgres/postgres.d.ts +44 -188
- package/build/services/store/providers/postgres/postgres.js +480 -285
- package/build/types/activity.d.ts +2 -0
- package/build/types/hmsh_escalations.d.ts +212 -0
- package/build/types/index.d.ts +1 -1
- package/build/types/provider.d.ts +2 -0
- package/package.json +2 -1
- package/build/types/signal.d.ts +0 -147
- /package/build/types/{signal.js → hmsh_escalations.js} +0 -0
|
@@ -11,54 +11,79 @@ const collator_2 = require("../../types/collator");
|
|
|
11
11
|
const utils_1 = require("../../modules/utils");
|
|
12
12
|
const activity_1 = require("./activity");
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* (immediate transition with optional data mapping).
|
|
14
|
+
* The most flexible activity type in the HotMesh YAML DAG. Depending on
|
|
15
|
+
* its configuration it operates as one of four distinct flavors:
|
|
17
16
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* | Flavor | Key field | Behavior |
|
|
18
|
+
* |---|---|---|
|
|
19
|
+
* | **Time** | `sleep` | Pauses for a duration, then resumes |
|
|
20
|
+
* | **Signal** | `hook.topic` | Pauses until an external signal arrives |
|
|
21
|
+
* | **Cycle** | `cycle: true` | Passthrough that also accepts re-entry from a `cycle` activity |
|
|
22
|
+
* | **Passthrough** | _(none)_ | Maps data and transitions immediately |
|
|
20
23
|
*
|
|
21
|
-
*
|
|
24
|
+
* ---
|
|
22
25
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
+
* ## Flavor 1 — Time Hook (sleep)
|
|
27
|
+
*
|
|
28
|
+
* Pauses the flow for `sleep` seconds. The value can be a literal number
|
|
29
|
+
* or a `@pipe` expression (e.g., for exponential backoff).
|
|
26
30
|
*
|
|
27
31
|
* ```yaml
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* graphs:
|
|
32
|
-
* - subscribes: job.start
|
|
33
|
-
* expire: 300
|
|
32
|
+
* activities:
|
|
33
|
+
* t1:
|
|
34
|
+
* type: trigger
|
|
34
35
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
36
|
+
* wait_30s:
|
|
37
|
+
* type: hook
|
|
38
|
+
* sleep: 30 # pause for 30 seconds
|
|
39
|
+
* job:
|
|
40
|
+
* maps:
|
|
41
|
+
* paused_at: '{$self.output.metadata.ac}'
|
|
38
42
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* sleep: 60 # pause for 60 seconds
|
|
42
|
-
* job:
|
|
43
|
-
* maps:
|
|
44
|
-
* paused_at: '{$self.output.metadata.ac}'
|
|
43
|
+
* next_step:
|
|
44
|
+
* type: hook
|
|
45
45
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* transitions:
|
|
47
|
+
* t1:
|
|
48
|
+
* - to: wait_30s
|
|
49
|
+
* wait_30s:
|
|
50
|
+
* - to: next_step
|
|
51
|
+
* ```
|
|
48
52
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
53
|
+
* Dynamic delay with `@pipe` (exponential backoff on retry):
|
|
54
|
+
*
|
|
55
|
+
* ```yaml
|
|
56
|
+
* wait_retry:
|
|
57
|
+
* type: hook
|
|
58
|
+
* sleep:
|
|
59
|
+
* '@pipe':
|
|
60
|
+
* - ['{$self.output.data.attempt}', 2]
|
|
61
|
+
* - ['{@math.pow}'] # 1, 2, 4, 8, 16 …
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* ---
|
|
65
|
+
*
|
|
66
|
+
* ## Flavor 2 — Signal Hook (webhook)
|
|
67
|
+
*
|
|
68
|
+
* Registers a listener on a named topic. The flow pauses until an
|
|
69
|
+
* external caller delivers a signal. Signal data is available as
|
|
70
|
+
* `$self.hook.data`. The graph-level `hooks` section routes incoming
|
|
71
|
+
* signals to the waiting activity via a `conditions.match` rule.
|
|
72
|
+
*
|
|
73
|
+
* **Send the signal** from any process:
|
|
74
|
+
*
|
|
75
|
+
* ```typescript
|
|
76
|
+
* await hotMesh.signal('order.approval', { id: jobId, approved: true });
|
|
54
77
|
* ```
|
|
55
78
|
*
|
|
56
|
-
*
|
|
79
|
+
* **Claim and delete** a pending signal via the collator key:
|
|
57
80
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
81
|
+
* ```typescript
|
|
82
|
+
* // Ack/delete — deliver a signal and clear the hook registration
|
|
83
|
+
* await hotMesh.signal('order.approval', { id: jobId, approved: false });
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* **YAML configuration:**
|
|
62
87
|
*
|
|
63
88
|
* ```yaml
|
|
64
89
|
* app:
|
|
@@ -92,20 +117,78 @@ const activity_1 = require("./activity");
|
|
|
92
117
|
* - to: done
|
|
93
118
|
*
|
|
94
119
|
* hooks:
|
|
95
|
-
* order.approval:
|
|
120
|
+
* order.approval: # topic delivering the signal
|
|
96
121
|
* - to: wait_for_approval
|
|
97
122
|
* conditions:
|
|
98
123
|
* match:
|
|
99
|
-
* - expected: '{t1.output.data.id}'
|
|
100
|
-
* actual: '{$self.hook.data.id}'
|
|
124
|
+
* - expected: '{t1.output.data.id}' # job ID
|
|
125
|
+
* actual: '{$self.hook.data.id}' # signal payload ID
|
|
101
126
|
* ```
|
|
102
127
|
*
|
|
103
|
-
*
|
|
128
|
+
* ### Signal Hook with Escalation
|
|
104
129
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
130
|
+
* Adding an `escalation:` block causes the hook activity to write one row
|
|
131
|
+
* to `public.hmsh_escalations` atomically inside its Leg 1 transaction —
|
|
132
|
+
* the same database commit that checkpoints job state. The row is
|
|
133
|
+
* immediately queryable and claimable by any external system.
|
|
134
|
+
*
|
|
135
|
+
* All field values support `@pipe` expressions so they can reference job
|
|
136
|
+
* data computed by earlier activities (e.g., `'{t1.output.data.region}'`).
|
|
137
|
+
*
|
|
138
|
+
* ```yaml
|
|
139
|
+
* wait_for_approval:
|
|
140
|
+
* type: hook
|
|
141
|
+
* hook:
|
|
142
|
+
* type: object
|
|
143
|
+
* properties:
|
|
144
|
+
* approved: { type: boolean }
|
|
145
|
+
* escalation:
|
|
146
|
+
* role: manager # RBAC role that should act
|
|
147
|
+
* type: order-approval
|
|
148
|
+
* subtype: regional
|
|
149
|
+
* priority: 2 # lower = higher priority
|
|
150
|
+
* description: Approve or reject the order
|
|
151
|
+
* entity: '{t1.output.data.entityType}'
|
|
152
|
+
* metadata:
|
|
153
|
+
* orderId: '{t1.output.data.orderId}'
|
|
154
|
+
* region: '{t1.output.data.region}'
|
|
155
|
+
* envelope:
|
|
156
|
+
* instructions: Review the attached order and approve or reject
|
|
157
|
+
* expiresAt: '{t1.output.data.dueDate}'
|
|
158
|
+
* job:
|
|
159
|
+
* maps:
|
|
160
|
+
* approved: '{$self.hook.data.approved}'
|
|
161
|
+
* ```
|
|
162
|
+
*
|
|
163
|
+
* **Claim and resolve the escalation** (resumes the waiting workflow):
|
|
164
|
+
*
|
|
165
|
+
* ```typescript
|
|
166
|
+
* // Find pending approvals for the manager role
|
|
167
|
+
* const [item] = await client.escalations.list({ role: 'manager', status: 'pending' });
|
|
168
|
+
*
|
|
169
|
+
* // Claim it (sets assigned_to + claim_expires_at)
|
|
170
|
+
* const claim = await client.escalations.claim({
|
|
171
|
+
* id: item.id,
|
|
172
|
+
* assignee: 'alice@company.com',
|
|
173
|
+
* durationMinutes: 30,
|
|
174
|
+
* });
|
|
175
|
+
*
|
|
176
|
+
* // Resolve atomically delivers the signal and resumes the workflow
|
|
177
|
+
* await client.escalations.resolve({
|
|
178
|
+
* id: item.id,
|
|
179
|
+
* resolverPayload: { approved: true },
|
|
180
|
+
* });
|
|
181
|
+
* ```
|
|
182
|
+
*
|
|
183
|
+
* ---
|
|
184
|
+
*
|
|
185
|
+
* ## Flavor 3 — Cycle Pivot
|
|
186
|
+
*
|
|
187
|
+
* A passthrough hook with `cycle: true` acts as the named re-entry point
|
|
188
|
+
* for a `cycle` activity. On first entry it behaves identically to a
|
|
189
|
+
* passthrough (maps data, transitions forward). When a `cycle` activity
|
|
190
|
+
* downstream names it as its `ancestor`, the engine routes execution back
|
|
191
|
+
* to it, allowing a controlled loop without spawning a new job.
|
|
109
192
|
*
|
|
110
193
|
* ```yaml
|
|
111
194
|
* app:
|
|
@@ -113,6 +196,7 @@ const activity_1 = require("./activity");
|
|
|
113
196
|
* version: '1'
|
|
114
197
|
* graphs:
|
|
115
198
|
* - subscribes: job.start
|
|
199
|
+
* expire: 120
|
|
116
200
|
*
|
|
117
201
|
* activities:
|
|
118
202
|
* t1:
|
|
@@ -120,34 +204,69 @@ const activity_1 = require("./activity");
|
|
|
120
204
|
*
|
|
121
205
|
* pivot:
|
|
122
206
|
* type: hook
|
|
123
|
-
* cycle: true
|
|
207
|
+
* cycle: true # marks this as a loop re-entry point
|
|
208
|
+
*
|
|
209
|
+
* do_work:
|
|
210
|
+
* type: worker
|
|
211
|
+
* topic: work.process
|
|
124
212
|
* output:
|
|
125
|
-
*
|
|
126
|
-
*
|
|
213
|
+
* schema:
|
|
214
|
+
* type: object
|
|
215
|
+
* properties:
|
|
216
|
+
* counter: { type: number }
|
|
127
217
|
* job:
|
|
128
218
|
* maps:
|
|
129
|
-
* counter: '{$self.output.data.
|
|
219
|
+
* counter: '{$self.output.data.counter}'
|
|
130
220
|
*
|
|
131
|
-
*
|
|
132
|
-
* type:
|
|
133
|
-
*
|
|
221
|
+
* loop_back:
|
|
222
|
+
* type: cycle
|
|
223
|
+
* ancestor: pivot # jumps back to `pivot` when condition holds
|
|
134
224
|
*
|
|
135
225
|
* transitions:
|
|
136
226
|
* t1:
|
|
137
227
|
* - to: pivot
|
|
138
228
|
* pivot:
|
|
139
229
|
* - to: do_work
|
|
230
|
+
* do_work:
|
|
231
|
+
* - to: loop_back
|
|
232
|
+
* conditions:
|
|
233
|
+
* match:
|
|
234
|
+
* - expected: true
|
|
235
|
+
* actual:
|
|
236
|
+
* '@pipe':
|
|
237
|
+
* - ['{do_work.output.data.counter}', 5]
|
|
238
|
+
* - ['{@conditional.less_than}']
|
|
140
239
|
* ```
|
|
141
240
|
*
|
|
241
|
+
* ---
|
|
242
|
+
*
|
|
243
|
+
* ## Flavor 4 — Passthrough
|
|
244
|
+
*
|
|
245
|
+
* When none of `sleep`, `hook`, or `cycle` is set, the hook activity
|
|
246
|
+
* immediately maps data and transitions to its children. Useful as a
|
|
247
|
+
* data transformation node or fan-in convergence point.
|
|
248
|
+
*
|
|
249
|
+
* ```yaml
|
|
250
|
+
* merge:
|
|
251
|
+
* type: hook
|
|
252
|
+
* output:
|
|
253
|
+
* maps:
|
|
254
|
+
* total: '{a1.output.data.subtotal}' # copy field into activity output
|
|
255
|
+
* job:
|
|
256
|
+
* maps:
|
|
257
|
+
* total: '{$self.output.data.total}' # promote to job-level data
|
|
258
|
+
* ```
|
|
259
|
+
*
|
|
260
|
+
* ---
|
|
261
|
+
*
|
|
142
262
|
* ## Execution Model
|
|
143
263
|
*
|
|
144
|
-
* - **
|
|
145
|
-
* hook
|
|
146
|
-
* external signal arrives
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* to adjacent activities.
|
|
264
|
+
* - **Time and Signal flavors** — Category A (duplex). Leg 1 registers the
|
|
265
|
+
* hook (timer or webhook), saves state, and commits. Leg 2 fires when the
|
|
266
|
+
* timer fires or the external signal arrives.
|
|
267
|
+
* - **Cycle and Passthrough flavors** — Category B. Uses the crash-safe
|
|
268
|
+
* `executeLeg1StepProtocol` (GUID ledger backed) to map data and
|
|
269
|
+
* immediately transition to adjacent activities.
|
|
151
270
|
*
|
|
152
271
|
* @see {@link HookActivity} for the TypeScript interface
|
|
153
272
|
*/
|
|
@@ -225,6 +344,10 @@ class Hook extends activity_1.Activity {
|
|
|
225
344
|
await this.setState(transaction);
|
|
226
345
|
await collator_1.CollatorService.notarizeLeg1Completion(this, transaction);
|
|
227
346
|
await this.setStatus(0, transaction);
|
|
347
|
+
//enqueue escalation INSERT inside the Leg1 transaction so it is
|
|
348
|
+
//written atomically with the job state checkpoint — one committed
|
|
349
|
+
//unit, crash-safe, no separate recovery path needed.
|
|
350
|
+
await this.addEscalationToTransaction(transaction);
|
|
228
351
|
await transaction.exec();
|
|
229
352
|
telemetry.mapActivityAttributes();
|
|
230
353
|
//register the web hook signal AFTER the transaction commits.
|
|
@@ -238,6 +361,69 @@ class Hook extends activity_1.Activity {
|
|
|
238
361
|
await this.redeliverPendingSignal(pending);
|
|
239
362
|
}
|
|
240
363
|
}
|
|
364
|
+
async addEscalationToTransaction(transaction) {
|
|
365
|
+
if (!this.config.escalation || !this.config.hook?.topic)
|
|
366
|
+
return;
|
|
367
|
+
const store = this.store;
|
|
368
|
+
if (typeof store.addEscalationToTransaction !== 'function')
|
|
369
|
+
return;
|
|
370
|
+
const escalationConfig = this.config.escalation;
|
|
371
|
+
const jid = this.context.metadata.jid;
|
|
372
|
+
const appId = this.engine.appId;
|
|
373
|
+
const namespace = this.engine.namespace ?? appId;
|
|
374
|
+
const resolveField = (v) => typeof v === 'string' ? pipe_1.Pipe.resolve(v, this.context) : v;
|
|
375
|
+
// Resolve metadata/envelope: a string value is a pipe expression that resolves
|
|
376
|
+
// to an object (factory path); an object value has per-key pipe expressions
|
|
377
|
+
// (YAML DAG path).
|
|
378
|
+
const resolveObj = (v) => {
|
|
379
|
+
if (typeof v === 'string')
|
|
380
|
+
return pipe_1.Pipe.resolve(v, this.context);
|
|
381
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
382
|
+
return v;
|
|
383
|
+
const out = {};
|
|
384
|
+
for (const [k, val] of Object.entries(v)) {
|
|
385
|
+
out[k] = typeof val === 'string' ? pipe_1.Pipe.resolve(val, this.context) : val;
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
};
|
|
389
|
+
const params = {
|
|
390
|
+
type: resolveField(escalationConfig.type),
|
|
391
|
+
subtype: resolveField(escalationConfig.subtype),
|
|
392
|
+
entity: resolveField(escalationConfig.entity),
|
|
393
|
+
description: resolveField(escalationConfig.description),
|
|
394
|
+
role: resolveField(escalationConfig.role),
|
|
395
|
+
priority: resolveField(escalationConfig.priority),
|
|
396
|
+
originId: resolveField(escalationConfig.originId),
|
|
397
|
+
parentId: resolveField(escalationConfig.parentId),
|
|
398
|
+
initiatedBy: resolveField(escalationConfig.initiatedBy),
|
|
399
|
+
traceId: resolveField(escalationConfig.traceId),
|
|
400
|
+
spanId: resolveField(escalationConfig.spanId),
|
|
401
|
+
metadata: resolveObj(escalationConfig.metadata),
|
|
402
|
+
envelope: resolveObj(escalationConfig.envelope),
|
|
403
|
+
expiresAt: resolveField(escalationConfig.expiresAt),
|
|
404
|
+
taskQueue: resolveField(escalationConfig.taskQueue),
|
|
405
|
+
workflowType: resolveField(escalationConfig.workflowType),
|
|
406
|
+
};
|
|
407
|
+
// Skip the INSERT when no escalation fields resolved — this happens when
|
|
408
|
+
// the factory waiter runs for a condition() call that had no queueConfig.
|
|
409
|
+
if (params.role == null && params.type == null &&
|
|
410
|
+
params.priority == null && params.metadata == null)
|
|
411
|
+
return;
|
|
412
|
+
// Derive signal_key from the hook rule's expected condition — the same
|
|
413
|
+
// value registerWebHook stores as the signal lookup key.
|
|
414
|
+
const hookRule = await this.getHookRule(this.config.hook.topic);
|
|
415
|
+
const signalKey = hookRule?.conditions?.match?.[0]?.expected
|
|
416
|
+
? pipe_1.Pipe.resolve(hookRule.conditions.match[0].expected, this.context)
|
|
417
|
+
: jid;
|
|
418
|
+
store.addEscalationToTransaction({
|
|
419
|
+
namespace,
|
|
420
|
+
appId,
|
|
421
|
+
signalKey,
|
|
422
|
+
topic: this.config.hook.topic,
|
|
423
|
+
workflowId: jid,
|
|
424
|
+
...params,
|
|
425
|
+
}, transaction);
|
|
426
|
+
}
|
|
241
427
|
/**
|
|
242
428
|
* Re-publishes a pending signal as a WEBHOOK stream message so the
|
|
243
429
|
* normal leg2 dispatch path processes it. Called when leg1's
|
|
@@ -87,7 +87,11 @@ class Trigger extends activity_1.Activity {
|
|
|
87
87
|
const { id: appId } = await this.engine.getVID();
|
|
88
88
|
//═══ Step 1: Inception (atomic job creation + GUID seed) ═══
|
|
89
89
|
const txn1 = this.store.transact();
|
|
90
|
-
|
|
90
|
+
// Resolve lineage from the incoming message so that origin_id and parent_id
|
|
91
|
+
// are written atomically with the job record — no separate update needed.
|
|
92
|
+
const originId = pipe_1.Pipe.resolve('{$self.input.data.originJobId}', this.context);
|
|
93
|
+
const parentId = pipe_1.Pipe.resolve('{$self.input.data.parentWorkflowId}', this.context);
|
|
94
|
+
await this.store.setStateNX(this.context.metadata.jid, appId, initialStatus, options?.entity || resolvedEntity, txn1, originId || undefined, parentId || undefined);
|
|
91
95
|
await this.store.collateSynthetic(this.context.metadata.jid, msgGuid, collator_1.CollatorService.WEIGHTS.STEP1_WORK, txn1);
|
|
92
96
|
const results1 = (await txn1.exec());
|
|
93
97
|
const jobCreated = Number(results1[0]) > 0;
|