@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
package/README.md CHANGED
@@ -14,7 +14,7 @@ npm install @hotmeshio/hotmesh
14
14
  - **Crash-safe execution** — Every step is a committed row. If the process dies, it picks up where it left off.
15
15
  - **Distributed state machines** — Build stateful applications where every component can [fail and recover](https://github.com/hotmeshio/sdk-typescript/blob/main/services/collator/README.md).
16
16
  - **AI and training pipelines** — Multi-step AI workloads where each stage is expensive and must not be repeated on failure. A crashed pipeline resumes from the last committed step, not from the beginning.
17
- - **Human-in-the-loop queues** — Suspend workflows until an operator claims and resolves the task. Pending suspensions become discoverable, claimable rows in Postgres. Concurrent workers receive exact reason codes (`conflict`, `already-resolved`, `signal-failed`) instead of opaque nulls.
17
+ - **Human-in-the-loop workflows** — Workflow pauses that require external input are searchable, claimable rows in your database. Build approval queues, review flows, and AI handoffs without a separate task service.
18
18
 
19
19
  ## How it works in 30 seconds
20
20
 
@@ -167,35 +167,24 @@ const result = await Durable.workflow.executeChild({
167
167
  **Signals** — pause a workflow until an external event arrives.
168
168
 
169
169
  ```typescript
170
- // Basic: pause until any caller sends the matching signal
171
170
  const approval = await Durable.workflow.condition<{ approved: boolean }>('manager-approval');
172
171
  if (!approval.approved) return 'rejected';
173
172
  ```
174
173
 
175
- Pass a `ConditionQueueConfig` as the second argument to also create a claimable task record in Postgres (see [Signal queue](#signal-queue-postgres-only) below):
174
+ **Human-in-the-loop** — When a workflow pauses waiting for input, it can write a searchable, claimable row to the database. External systems — dashboards, AI agents, APIs — query by role and metadata, claim ownership, and resolve to resume the workflow. The pause lives in `public.hmsh_escalations` alongside the rest of your workflow state.
176
175
 
177
176
  ```typescript
178
- // With queue config: atomically suspend the workflow AND enqueue a claimable task record
179
- const approval = await Durable.workflow.condition<{ approved: boolean }>('manager-approval', {
180
- role: 'manager',
181
- description: `Approve order ${orderId}`,
182
- metadata: { orderId, region: 'us-west' }, // GIN-indexed; used for claimByMetadata queries
183
- envelope: { formSchema: [...] }, // unindexed display context; safe for large blobs
184
- });
185
- // approval is false if a timeout was set and no signal arrived
186
- if (approval === false) return 'timed-out';
187
- if (!approval.approved) return 'rejected';
188
- ```
189
-
190
- The optional first string argument is a timeout; the queue config can appear as the second or third argument:
191
-
192
- ```typescript
193
- // With timeout AND queue config
194
- const approval = await Durable.workflow.condition<{ approved: boolean }>(
195
- 'manager-approval',
196
- '72 hours', // workflow times out if nobody acts
197
- { role: 'manager', metadata: { orderId } },
177
+ // workflow: pause and write a claimable row
178
+ const decision = await Durable.workflow.condition<{ approved: boolean }>(
179
+ 'approval',
180
+ { role: 'manager', type: 'order-review', metadata: { orderId } },
198
181
  );
182
+
183
+ // elsewhere: find pending approvals, claim one, resolve it
184
+ const [pending] = await client.escalations.list({ role: 'manager', status: 'pending' });
185
+ await client.escalations.claim({ id: pending.id, assignee: 'alice@company.com' });
186
+ await client.escalations.resolve({ id: pending.id, resolverPayload: { approved: true } });
187
+ // the workflow resumes with { approved: true }
199
188
  ```
200
189
 
201
190
  ## Retries and error handling
@@ -271,112 +260,6 @@ const exported = await handle.export({ // selective export
271
260
  });
272
261
  ```
273
262
 
274
- ## Signal queue (Postgres only)
275
-
276
- When `condition()` is called with a `ConditionQueueConfig`, HotMesh atomically writes a row to `hotmesh_signals` inside the same Postgres transaction that suspends the workflow. The result is a durable, queryable task queue backed by the same database — no separate service, no double-write, no gap between "task created" and "workflow suspended".
277
-
278
- **Workflow side** — declare what kind of task this suspension represents:
279
-
280
- ```typescript
281
- // workflows/approval.ts
282
- export async function orderApproval(orderId: string) {
283
- const signalId = `approve-${orderId}`;
284
-
285
- const result = await Durable.workflow.condition<{ approved: boolean; notes: string }>(
286
- signalId,
287
- {
288
- role: 'pharmacist',
289
- type: 'approval',
290
- priority: 3,
291
- description: `Review order ${orderId}`,
292
- taskQueue: 'rx-approvals',
293
- workflowType: 'orderApproval',
294
- metadata: { orderId }, // GIN-indexed; query with claimByMetadata
295
- envelope: { // not indexed; pass form schemas, display context
296
- formSchema: [{ name: 'notes', type: 'textarea', required: true }],
297
- },
298
- },
299
- );
300
-
301
- if (result === false) return { status: 'timed-out' };
302
- return result;
303
- }
304
- ```
305
-
306
- **Resolver side** — claim and resolve from any process (API handler, worker, dashboard):
307
-
308
- ```typescript
309
- // resolver.ts
310
- import { Durable } from '@hotmeshio/hotmesh';
311
-
312
- const client = new Durable.Client({ connection });
313
-
314
- // List all pending tasks for a role
315
- const pending = await client.signalQueue.list({ role: 'pharmacist', status: 'pending' });
316
-
317
- // Claim by metadata — atomic; concurrent workers get 'conflict', not a silent null
318
- const claim = await client.signalQueue.claimByMetadata({
319
- key: 'orderId',
320
- value: orderId,
321
- assignee: 'jane@example.com',
322
- durationMinutes: 30,
323
- });
324
-
325
- if (!claim.ok) {
326
- // claim.reason: 'not-found' (nothing queued) | 'conflict' (another worker beat you)
327
- return;
328
- }
329
-
330
- // Resolve — marks the DB record resolved AND delivers the signal to the paused workflow
331
- const resolution = await client.signalQueue.resolve({
332
- id: claim.entry.id,
333
- resolverPayload: { approved: true, notes: 'LGTM' },
334
- });
335
-
336
- if (!resolution.ok) {
337
- if (resolution.reason === 'signal-failed') {
338
- // DB updated but workflow signal not delivered — retry with resolution.signalKey
339
- }
340
- }
341
- ```
342
-
343
- **All `signalQueue` methods:**
344
-
345
- | Method | Description |
346
- |---|---|
347
- | `list(params)` | List signals filtered by `role`, `status`, `taskQueue` |
348
- | `get(id)` | Fetch a single signal record by UUID |
349
- | `claim({ id, assignee?, durationMinutes? })` | Claim by record ID |
350
- | `claimByMetadata({ key, value, assignee?, durationMinutes? })` | Claim by GIN-indexed metadata field |
351
- | `release({ id })` | Return a claimed signal to `pending` |
352
- | `releaseExpired()` | Sweep all past-expiry claims back to `pending` |
353
- | `resolve({ id, resolverPayload? })` | Resolve by ID and deliver signal to the workflow |
354
- | `resolveByMetadata({ key, value, resolverPayload? })` | Resolve by metadata field and deliver signal |
355
-
356
- **Result types** — all mutating methods return a discriminated union so callers can handle every outcome without guessing what `null` meant:
357
-
358
- ```typescript
359
- // Claim
360
- type ClaimSignalResult =
361
- | { ok: true; entry: SignalQueueEntry }
362
- | { ok: false; reason: 'not-found' | 'conflict' };
363
-
364
- // Release
365
- type ReleaseSignalResult =
366
- | { ok: true }
367
- | { ok: false; reason: 'not-found' | 'wrong-status' };
368
-
369
- // Resolve
370
- type ResolveSignalResult =
371
- | { ok: true }
372
- | { ok: false; reason: 'not-found' | 'already-resolved' }
373
- | { ok: false; reason: 'signal-failed'; signalKey: string };
374
- ```
375
-
376
- `signal-failed` means the database record was updated but `workflow.signal()` threw (network blip, workflow already complete). The `signalKey` is returned so callers can retry signal delivery independently without re-resolving the record.
377
-
378
- > **Postgres only.** The `signalQueue.*` methods require the Postgres provider. Workflows using `condition()` without a queue config are unaffected on any provider.
379
-
380
263
  ## Observability
381
264
 
382
265
  There is no proprietary dashboard. Workflow state lives in Postgres, so use whatever tools you already have:
@@ -240,6 +240,9 @@ function getValueByPath(obj, path) {
240
240
  const pathParts = path.split('/');
241
241
  let currentValue = obj;
242
242
  for (const part of pathParts) {
243
+ if (currentValue == null || typeof currentValue !== 'object') {
244
+ return undefined;
245
+ }
243
246
  if (currentValue[part] !== undefined) {
244
247
  currentValue = currentValue[part];
245
248
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -50,6 +50,7 @@
50
50
  "test:durable:signal": "vitest run tests/durable/signal/postgres.test.ts",
51
51
  "test:durable:readonly": "docker compose --profile readonly up -d --build && docker compose exec hotmesh-readonly npx vitest run --config tests/durable/readonly/vitest.config.mts",
52
52
  "test:durable:unknown": "vitest run tests/durable/unknown/postgres.test.ts",
53
+ "test:durable:esclations": "HMSH_LOGLEVEL=info vitest run tests/durable/escalations",
53
54
  "test:durable:exporter": "HMSH_LOGLEVEL=info vitest run tests/durable/exporter",
54
55
  "test:durable:exporter:debug": "EXPORT_DEBUG=1 HMSH_LOGLEVEL=error vitest run tests/durable/basic/postgres.test.ts",
55
56
  "test:durable:codec": "vitest run tests/durable/codec/postgres.test.ts",
@@ -6,54 +6,79 @@ import { ProviderTransaction } from '../../types/provider';
6
6
  import { StreamCode, StreamStatus } from '../../types/stream';
7
7
  import { Activity } from './activity';
8
8
  /**
9
- * A versatile pause/resume activity that supports three distinct patterns:
10
- * **time hook** (sleep), **web hook** (external signal), and **passthrough**
11
- * (immediate transition with optional data mapping).
9
+ * The most flexible activity type in the HotMesh YAML DAG. Depending on
10
+ * its configuration it operates as one of four distinct flavors:
12
11
  *
13
- * The hook activity is the most flexible activity type. Depending on its
14
- * YAML configuration, it operates in one of the following modes:
12
+ * | Flavor | Key field | Behavior |
13
+ * |---|---|---|
14
+ * | **Time** | `sleep` | Pauses for a duration, then resumes |
15
+ * | **Signal** | `hook.topic` | Pauses until an external signal arrives |
16
+ * | **Cycle** | `cycle: true` | Passthrough that also accepts re-entry from a `cycle` activity |
17
+ * | **Passthrough** | _(none)_ | Maps data and transitions immediately |
15
18
  *
16
- * ## Time Hook (Sleep)
19
+ * ---
17
20
  *
18
- * Pauses the flow for a specified duration in seconds. The `sleep` value
19
- * can be a literal number or a `@pipe` expression for dynamic delays
20
- * (e.g., exponential backoff).
21
+ * ## Flavor 1 Time Hook (sleep)
22
+ *
23
+ * Pauses the flow for `sleep` seconds. The value can be a literal number
24
+ * or a `@pipe` expression (e.g., for exponential backoff).
21
25
  *
22
26
  * ```yaml
23
- * app:
24
- * id: myapp
25
- * version: '1'
26
- * graphs:
27
- * - subscribes: job.start
28
- * expire: 300
27
+ * activities:
28
+ * t1:
29
+ * type: trigger
29
30
  *
30
- * activities:
31
- * t1:
32
- * type: trigger
31
+ * wait_30s:
32
+ * type: hook
33
+ * sleep: 30 # pause for 30 seconds
34
+ * job:
35
+ * maps:
36
+ * paused_at: '{$self.output.metadata.ac}'
33
37
  *
34
- * delay:
35
- * type: hook
36
- * sleep: 60 # pause for 60 seconds
37
- * job:
38
- * maps:
39
- * paused_at: '{$self.output.metadata.ac}'
38
+ * next_step:
39
+ * type: hook
40
40
  *
41
- * resume:
42
- * type: hook
41
+ * transitions:
42
+ * t1:
43
+ * - to: wait_30s
44
+ * wait_30s:
45
+ * - to: next_step
46
+ * ```
43
47
  *
44
- * transitions:
45
- * t1:
46
- * - to: delay
47
- * delay:
48
- * - to: resume
48
+ * Dynamic delay with `@pipe` (exponential backoff on retry):
49
+ *
50
+ * ```yaml
51
+ * wait_retry:
52
+ * type: hook
53
+ * sleep:
54
+ * '@pipe':
55
+ * - ['{$self.output.data.attempt}', 2]
56
+ * - ['{@math.pow}'] # 1, 2, 4, 8, 16 …
49
57
  * ```
50
58
  *
51
- * ## Web Hook (External Signal)
59
+ * ---
52
60
  *
53
- * Registers a webhook listener on a named topic. The flow pauses until
54
- * an external signal is sent to the hook's topic. The signal data becomes
55
- * available as `$self.hook.data`. The `hooks` section at the graph level
56
- * routes incoming signals to the waiting activity.
61
+ * ## Flavor 2 Signal Hook (webhook)
62
+ *
63
+ * Registers a listener on a named topic. The flow pauses until an
64
+ * external caller delivers a signal. Signal data is available as
65
+ * `$self.hook.data`. The graph-level `hooks` section routes incoming
66
+ * signals to the waiting activity via a `conditions.match` rule.
67
+ *
68
+ * **Send the signal** from any process:
69
+ *
70
+ * ```typescript
71
+ * await hotMesh.signal('order.approval', { id: jobId, approved: true });
72
+ * ```
73
+ *
74
+ * **Claim and delete** a pending signal via the collator key:
75
+ *
76
+ * ```typescript
77
+ * // Ack/delete — deliver a signal and clear the hook registration
78
+ * await hotMesh.signal('order.approval', { id: jobId, approved: false });
79
+ * ```
80
+ *
81
+ * **YAML configuration:**
57
82
  *
58
83
  * ```yaml
59
84
  * app:
@@ -87,20 +112,78 @@ import { Activity } from './activity';
87
112
  * - to: done
88
113
  *
89
114
  * hooks:
90
- * order.approval: # external topic that delivers the signal
115
+ * order.approval: # topic delivering the signal
91
116
  * - to: wait_for_approval
92
117
  * conditions:
93
118
  * match:
94
- * - expected: '{t1.output.data.id}'
95
- * actual: '{$self.hook.data.id}'
119
+ * - expected: '{t1.output.data.id}' # job ID
120
+ * actual: '{$self.hook.data.id}' # signal payload ID
121
+ * ```
122
+ *
123
+ * ### Signal Hook with Escalation
124
+ *
125
+ * Adding an `escalation:` block causes the hook activity to write one row
126
+ * to `public.hmsh_escalations` atomically inside its Leg 1 transaction —
127
+ * the same database commit that checkpoints job state. The row is
128
+ * immediately queryable and claimable by any external system.
129
+ *
130
+ * All field values support `@pipe` expressions so they can reference job
131
+ * data computed by earlier activities (e.g., `'{t1.output.data.region}'`).
132
+ *
133
+ * ```yaml
134
+ * wait_for_approval:
135
+ * type: hook
136
+ * hook:
137
+ * type: object
138
+ * properties:
139
+ * approved: { type: boolean }
140
+ * escalation:
141
+ * role: manager # RBAC role that should act
142
+ * type: order-approval
143
+ * subtype: regional
144
+ * priority: 2 # lower = higher priority
145
+ * description: Approve or reject the order
146
+ * entity: '{t1.output.data.entityType}'
147
+ * metadata:
148
+ * orderId: '{t1.output.data.orderId}'
149
+ * region: '{t1.output.data.region}'
150
+ * envelope:
151
+ * instructions: Review the attached order and approve or reject
152
+ * expiresAt: '{t1.output.data.dueDate}'
153
+ * job:
154
+ * maps:
155
+ * approved: '{$self.hook.data.approved}'
156
+ * ```
157
+ *
158
+ * **Claim and resolve the escalation** (resumes the waiting workflow):
159
+ *
160
+ * ```typescript
161
+ * // Find pending approvals for the manager role
162
+ * const [item] = await client.escalations.list({ role: 'manager', status: 'pending' });
163
+ *
164
+ * // Claim it (sets assigned_to + claim_expires_at)
165
+ * const claim = await client.escalations.claim({
166
+ * id: item.id,
167
+ * assignee: 'alice@company.com',
168
+ * durationMinutes: 30,
169
+ * });
170
+ *
171
+ * // Resolve atomically delivers the signal and resumes the workflow
172
+ * await client.escalations.resolve({
173
+ * id: item.id,
174
+ * resolverPayload: { approved: true },
175
+ * });
96
176
  * ```
97
177
  *
98
- * ## Passthrough (No Hook)
178
+ * ---
179
+ *
180
+ * ## Flavor 3 — Cycle Pivot
99
181
  *
100
- * When neither `sleep` nor `hook` is configured, the hook activity acts
101
- * as a passthrough: it maps data and immediately transitions to children.
102
- * This is useful for data transformation, convergence points, or as a
103
- * cycle pivot (with `cycle: true`).
182
+ * A passthrough hook with `cycle: true` acts as the named re-entry point
183
+ * for a `cycle` activity. On first entry it behaves identically to a
184
+ * passthrough (maps data, transitions forward). When a `cycle` activity
185
+ * downstream names it as its `ancestor`, the engine routes execution back
186
+ * to it, allowing a controlled loop without spawning a new job.
104
187
  *
105
188
  * ```yaml
106
189
  * app:
@@ -108,6 +191,7 @@ import { Activity } from './activity';
108
191
  * version: '1'
109
192
  * graphs:
110
193
  * - subscribes: job.start
194
+ * expire: 120
111
195
  *
112
196
  * activities:
113
197
  * t1:
@@ -115,34 +199,69 @@ import { Activity } from './activity';
115
199
  *
116
200
  * pivot:
117
201
  * type: hook
118
- * cycle: true # enables re-entry from a cycle activity
202
+ * cycle: true # marks this as a loop re-entry point
203
+ *
204
+ * do_work:
205
+ * type: worker
206
+ * topic: work.process
119
207
  * output:
120
- * maps:
121
- * retryCount: 0
208
+ * schema:
209
+ * type: object
210
+ * properties:
211
+ * counter: { type: number }
122
212
  * job:
123
213
  * maps:
124
- * counter: '{$self.output.data.retryCount}'
214
+ * counter: '{$self.output.data.counter}'
125
215
  *
126
- * do_work:
127
- * type: worker
128
- * topic: work.do
216
+ * loop_back:
217
+ * type: cycle
218
+ * ancestor: pivot # jumps back to `pivot` when condition holds
129
219
  *
130
220
  * transitions:
131
221
  * t1:
132
222
  * - to: pivot
133
223
  * pivot:
134
224
  * - to: do_work
225
+ * do_work:
226
+ * - to: loop_back
227
+ * conditions:
228
+ * match:
229
+ * - expected: true
230
+ * actual:
231
+ * '@pipe':
232
+ * - ['{do_work.output.data.counter}', 5]
233
+ * - ['{@conditional.less_than}']
135
234
  * ```
136
235
  *
236
+ * ---
237
+ *
238
+ * ## Flavor 4 — Passthrough
239
+ *
240
+ * When none of `sleep`, `hook`, or `cycle` is set, the hook activity
241
+ * immediately maps data and transitions to its children. Useful as a
242
+ * data transformation node or fan-in convergence point.
243
+ *
244
+ * ```yaml
245
+ * merge:
246
+ * type: hook
247
+ * output:
248
+ * maps:
249
+ * total: '{a1.output.data.subtotal}' # copy field into activity output
250
+ * job:
251
+ * maps:
252
+ * total: '{$self.output.data.total}' # promote to job-level data
253
+ * ```
254
+ *
255
+ * ---
256
+ *
137
257
  * ## Execution Model
138
258
  *
139
- * - **With `sleep` or `hook`**: Category A (duplex). Leg 1 registers the
140
- * hook and saves state. Leg 2 fires when the timer expires or the
141
- * external signal arrives (via `processTimeHookEvent` or
142
- * `processWebHookEvent`).
143
- * - **Without `sleep` or `hook`**: Category B (passthrough). Uses the
144
- * crash-safe `executeLeg1StepProtocol` to map data and transition
145
- * to adjacent activities.
259
+ * - **Time and Signal flavors** Category A (duplex). Leg 1 registers the
260
+ * hook (timer or webhook), saves state, and commits. Leg 2 fires when the
261
+ * timer fires or the external signal arrives.
262
+ * - **Cycle and Passthrough flavors** — Category B. Uses the crash-safe
263
+ * `executeLeg1StepProtocol` (GUID ledger backed) to map data and
264
+ * immediately transition to adjacent activities.
146
265
  *
147
266
  * @see {@link HookActivity} for the TypeScript interface
148
267
  */
@@ -152,6 +271,7 @@ declare class Hook extends Activity {
152
271
  isConfiguredAsHook(): boolean;
153
272
  doesHook(): boolean;
154
273
  doHook(telemetry: TelemetryService): Promise<void>;
274
+ private addEscalationToTransaction;
155
275
  /**
156
276
  * Re-publishes a pending signal as a WEBHOOK stream message so the
157
277
  * normal leg2 dispatch path processes it. Called when leg1's