@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.
Files changed (30) hide show
  1. package/README.md +12 -129
  2. package/build/modules/utils.js +3 -0
  3. package/build/package.json +2 -1
  4. package/build/services/activities/hook.d.ts +178 -58
  5. package/build/services/activities/hook.js +244 -58
  6. package/build/services/activities/trigger.js +5 -1
  7. package/build/services/durable/client.d.ts +238 -67
  8. package/build/services/durable/client.js +307 -131
  9. package/build/services/durable/index.d.ts +0 -2
  10. package/build/services/durable/schemas/factory.js +40 -0
  11. package/build/services/durable/worker.js +5 -28
  12. package/build/services/durable/workflow/condition.d.ts +69 -37
  13. package/build/services/durable/workflow/condition.js +70 -39
  14. package/build/services/hotmesh/index.d.ts +31 -4
  15. package/build/services/hotmesh/index.js +31 -4
  16. package/build/services/store/index.d.ts +1 -1
  17. package/build/services/store/providers/postgres/kvsql.d.ts +1 -1
  18. package/build/services/store/providers/postgres/kvtables.js +83 -122
  19. package/build/services/store/providers/postgres/kvtypes/hash/basic.d.ts +1 -1
  20. package/build/services/store/providers/postgres/kvtypes/hash/basic.js +8 -8
  21. package/build/services/store/providers/postgres/kvtypes/hash/index.d.ts +1 -1
  22. package/build/services/store/providers/postgres/postgres.d.ts +44 -188
  23. package/build/services/store/providers/postgres/postgres.js +480 -285
  24. package/build/types/activity.d.ts +2 -0
  25. package/build/types/hmsh_escalations.d.ts +212 -0
  26. package/build/types/index.d.ts +1 -1
  27. package/build/types/provider.d.ts +2 -0
  28. package/package.json +2 -1
  29. package/build/types/signal.d.ts +0 -147
  30. /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
- * A versatile pause/resume activity that supports three distinct patterns:
15
- * **time hook** (sleep), **web hook** (external signal), and **passthrough**
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
- * The hook activity is the most flexible activity type. Depending on its
19
- * YAML configuration, it operates in one of the following modes:
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
- * ## Time Hook (Sleep)
24
+ * ---
22
25
  *
23
- * Pauses the flow for a specified duration in seconds. The `sleep` value
24
- * can be a literal number or a `@pipe` expression for dynamic delays
25
- * (e.g., exponential backoff).
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
- * app:
29
- * id: myapp
30
- * version: '1'
31
- * graphs:
32
- * - subscribes: job.start
33
- * expire: 300
32
+ * activities:
33
+ * t1:
34
+ * type: trigger
34
35
  *
35
- * activities:
36
- * t1:
37
- * type: trigger
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
- * delay:
40
- * type: hook
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
- * resume:
47
- * type: hook
46
+ * transitions:
47
+ * t1:
48
+ * - to: wait_30s
49
+ * wait_30s:
50
+ * - to: next_step
51
+ * ```
48
52
  *
49
- * transitions:
50
- * t1:
51
- * - to: delay
52
- * delay:
53
- * - to: resume
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
- * ## Web Hook (External Signal)
79
+ * **Claim and delete** a pending signal via the collator key:
57
80
  *
58
- * Registers a webhook listener on a named topic. The flow pauses until
59
- * an external signal is sent to the hook's topic. The signal data becomes
60
- * available as `$self.hook.data`. The `hooks` section at the graph level
61
- * routes incoming signals to the waiting activity.
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: # external topic that delivers the signal
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
- * ## Passthrough (No Hook)
128
+ * ### Signal Hook with Escalation
104
129
  *
105
- * When neither `sleep` nor `hook` is configured, the hook activity acts
106
- * as a passthrough: it maps data and immediately transitions to children.
107
- * This is useful for data transformation, convergence points, or as a
108
- * cycle pivot (with `cycle: true`).
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 # enables re-entry from a cycle activity
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
- * maps:
126
- * retryCount: 0
213
+ * schema:
214
+ * type: object
215
+ * properties:
216
+ * counter: { type: number }
127
217
  * job:
128
218
  * maps:
129
- * counter: '{$self.output.data.retryCount}'
219
+ * counter: '{$self.output.data.counter}'
130
220
  *
131
- * do_work:
132
- * type: worker
133
- * topic: work.do
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
- * - **With `sleep` or `hook`**: Category A (duplex). Leg 1 registers the
145
- * hook and saves state. Leg 2 fires when the timer expires or the
146
- * external signal arrives (via `processTimeHookEvent` or
147
- * `processWebHookEvent`).
148
- * - **Without `sleep` or `hook`**: Category B (passthrough). Uses the
149
- * crash-safe `executeLeg1StepProtocol` to map data and transition
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
- await this.store.setStateNX(this.context.metadata.jid, appId, initialStatus, options?.entity || resolvedEntity, txn1);
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;