@hotmeshio/hotmesh 0.10.0 → 0.10.1

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 CHANGED
@@ -11,7 +11,7 @@ npm install @hotmeshio/hotmesh
11
11
  ## Use HotMesh for
12
12
 
13
13
  - **Durable pipelines** — Orchestrate long-running, multi-step pipelines transactionally.
14
- - **Temporal replacement** — The `Durable` module provides a Temporal-compatible API (`Client`, `Worker`, `proxyActivities`, `sleepFor`, `startChild`, signals) that runs directly on Postgres. No app server required.
14
+ - **Temporal alternative** — The `Durable` module provides a Temporal-compatible API (`Client`, `Worker`, `proxyActivities`, `sleepFor`, `startChild`, signals) that runs directly on Postgres. No app server required.
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
17
 
@@ -337,7 +337,7 @@ Durable is designed as a drop-in-compatible alternative for common Temporal patt
337
337
 
338
338
  **What's the same:** `Client`, `Worker`, `proxyActivities`, `sleepFor`, `startChild`/`execChild`, signals (`waitFor`/`signal`), retry policies, and the overall workflow-as-code programming model.
339
339
 
340
- **What's different:** No Temporal server or cluster to operate. Postgres is the only infrastructure dependency — it stores state, coordinates workers, and delivers messages. HotMesh also offers a YAML-based approach for declarative workflows that compile to the same execution model.
340
+ **What's different:** Postgres is the only infrastructure dependency — it stores state and coordinates workers.
341
341
 
342
342
  ## Running tests
343
343
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Permanent-Memory Workflows & AI Agents",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -110,13 +110,12 @@
110
110
  "nats": "^2.28.0",
111
111
  "openai": "^5.9.0",
112
112
  "pg": "^8.10.0",
113
- "rimraf": "^4.4.1",
113
+ "rimraf": "^6.1.3",
114
114
  "terser": "^5.37.0",
115
115
  "ts-node": "^10.9.1",
116
- "ts-node-dev": "^2.0.0",
117
116
  "typedoc": "^0.26.4",
118
117
  "typescript": "^5.0.4",
119
- "vitest": "^2.1.9"
118
+ "vitest": "^4.0.18"
120
119
  },
121
120
  "peerDependencies": {
122
121
  "nats": "^2.0.0",
@@ -364,12 +364,41 @@ declare class DurableClass {
364
364
  static didInterrupt: typeof didInterrupt;
365
365
  private static interceptorService;
366
366
  /**
367
- * Register a workflow interceptor
367
+ * Register a workflow interceptor that wraps the entire workflow execution
368
+ * in an onion-like pattern. Interceptors execute in registration order
369
+ * (first registered is outermost) and can perform actions before and after
370
+ * workflow execution, handle errors, and add cross-cutting concerns like
371
+ * logging, metrics, or tracing.
372
+ *
373
+ * Workflow interceptors run inside the workflow's async local storage context,
374
+ * so all Durable workflow methods (`proxyActivities`, `sleepFor`, `waitFor`,
375
+ * `execChild`, etc.) are available. When using Durable functions, always check
376
+ * for interruptions with `Durable.didInterrupt(err)` and rethrow them.
377
+ *
368
378
  * @param interceptor The interceptor to register
379
+ *
380
+ * @example
381
+ * ```typescript
382
+ * // Logging interceptor
383
+ * Durable.registerInterceptor({
384
+ * async execute(ctx, next) {
385
+ * console.log(`Workflow ${ctx.get('workflowName')} starting`);
386
+ * try {
387
+ * const result = await next();
388
+ * console.log(`Workflow ${ctx.get('workflowName')} completed`);
389
+ * return result;
390
+ * } catch (err) {
391
+ * if (Durable.didInterrupt(err)) throw err;
392
+ * console.error(`Workflow ${ctx.get('workflowName')} failed`);
393
+ * throw err;
394
+ * }
395
+ * }
396
+ * });
397
+ * ```
369
398
  */
370
399
  static registerInterceptor(interceptor: WorkflowInterceptor): void;
371
400
  /**
372
- * Clear all registered interceptors (both workflow and activity)
401
+ * Clear all registered interceptors (both workflow and activity).
373
402
  */
374
403
  static clearInterceptors(): void;
375
404
  /**
@@ -280,14 +280,43 @@ class DurableClass {
280
280
  */
281
281
  constructor() { }
282
282
  /**
283
- * Register a workflow interceptor
283
+ * Register a workflow interceptor that wraps the entire workflow execution
284
+ * in an onion-like pattern. Interceptors execute in registration order
285
+ * (first registered is outermost) and can perform actions before and after
286
+ * workflow execution, handle errors, and add cross-cutting concerns like
287
+ * logging, metrics, or tracing.
288
+ *
289
+ * Workflow interceptors run inside the workflow's async local storage context,
290
+ * so all Durable workflow methods (`proxyActivities`, `sleepFor`, `waitFor`,
291
+ * `execChild`, etc.) are available. When using Durable functions, always check
292
+ * for interruptions with `Durable.didInterrupt(err)` and rethrow them.
293
+ *
284
294
  * @param interceptor The interceptor to register
295
+ *
296
+ * @example
297
+ * ```typescript
298
+ * // Logging interceptor
299
+ * Durable.registerInterceptor({
300
+ * async execute(ctx, next) {
301
+ * console.log(`Workflow ${ctx.get('workflowName')} starting`);
302
+ * try {
303
+ * const result = await next();
304
+ * console.log(`Workflow ${ctx.get('workflowName')} completed`);
305
+ * return result;
306
+ * } catch (err) {
307
+ * if (Durable.didInterrupt(err)) throw err;
308
+ * console.error(`Workflow ${ctx.get('workflowName')} failed`);
309
+ * throw err;
310
+ * }
311
+ * }
312
+ * });
313
+ * ```
285
314
  */
286
315
  static registerInterceptor(interceptor) {
287
316
  DurableClass.interceptorService.register(interceptor);
288
317
  }
289
318
  /**
290
- * Clear all registered interceptors (both workflow and activity)
319
+ * Clear all registered interceptors (both workflow and activity).
291
320
  */
292
321
  static clearInterceptors() {
293
322
  DurableClass.interceptorService.clear();
@@ -181,6 +181,79 @@ import { WorkflowInterceptor, InterceptorRegistry, ActivityInterceptor, Activity
181
181
  * }
182
182
  * };
183
183
  * ```
184
+ *
185
+ * ## Activity Interceptors
186
+ *
187
+ * Activity interceptors wrap individual proxied activity calls, supporting
188
+ * both **before** and **after** phases. The before phase receives the activity
189
+ * input (and can modify `activityCtx.args`). The after phase receives the
190
+ * activity output as the return value of `next()`.
191
+ *
192
+ * This enables patterns like publishing activity results to an external
193
+ * system (e.g., SNS, audit log) without modifying the workflow itself.
194
+ *
195
+ * **Important:** The after-phase proxy activity calls go through the same
196
+ * interceptor chain. Guard against recursion by checking `activityCtx.activityName`
197
+ * to skip the interceptor's own calls.
198
+ *
199
+ * @example
200
+ * ```typescript
201
+ * import { Durable } from '@hotmeshio/hotmesh';
202
+ * import type { ActivityInterceptor } from '@hotmeshio/hotmesh/types/durable';
203
+ * import * as activities from './activities';
204
+ *
205
+ * // Activity interceptor that publishes results via a proxy activity
206
+ * const publishResultInterceptor: ActivityInterceptor = {
207
+ * async execute(activityCtx, workflowCtx, next) {
208
+ * try {
209
+ * // BEFORE: inspect or modify the activity input
210
+ * console.log(`Calling ${activityCtx.activityName}`, activityCtx.args);
211
+ *
212
+ * // Execute the activity (returns stored result on replay)
213
+ * const result = await next();
214
+ *
215
+ * // AFTER: use the activity output (only runs on replay,
216
+ * // once the result is available)
217
+ *
218
+ * // Guard: skip for the interceptor's own proxy calls
219
+ * if (activityCtx.activityName !== 'publishToSNS') {
220
+ * const { publishToSNS } = Durable.workflow.proxyActivities<{
221
+ * publishToSNS: (topic: string, payload: any) => Promise<void>;
222
+ * }>({
223
+ * taskQueue: 'shared-notifications',
224
+ * retryPolicy: { maximumAttempts: 3, throwOnError: true },
225
+ * });
226
+ *
227
+ * await publishToSNS('activity-results', {
228
+ * workflowId: workflowCtx.get('workflowId'),
229
+ * activityName: activityCtx.activityName,
230
+ * input: activityCtx.args,
231
+ * output: result,
232
+ * });
233
+ * }
234
+ *
235
+ * return result;
236
+ * } catch (err) {
237
+ * if (Durable.didInterrupt(err)) throw err;
238
+ * throw err;
239
+ * }
240
+ * },
241
+ * };
242
+ *
243
+ * Durable.registerActivityInterceptor(publishResultInterceptor);
244
+ * ```
245
+ *
246
+ * ## Activity Interceptor Replay Pattern
247
+ *
248
+ * Activity interceptors participate in the interruption/replay cycle:
249
+ *
250
+ * 1. **First execution**: Before-phase runs → `next()` registers the activity
251
+ * interruption and throws `DurableProxyError` → workflow pauses
252
+ * 2. **Second execution**: Before-phase replays → `next()` returns the stored
253
+ * activity result → after-phase runs → after-phase proxy call (e.g.,
254
+ * `publishToSNS`) registers its own interruption → workflow pauses
255
+ * 3. **Third execution**: Everything replays → after-phase proxy call returns
256
+ * its stored result → interceptor returns → workflow continues
184
257
  */
185
258
  export declare class InterceptorService implements InterceptorRegistry {
186
259
  interceptors: WorkflowInterceptor[];
@@ -183,6 +183,79 @@ exports.InterceptorService = void 0;
183
183
  * }
184
184
  * };
185
185
  * ```
186
+ *
187
+ * ## Activity Interceptors
188
+ *
189
+ * Activity interceptors wrap individual proxied activity calls, supporting
190
+ * both **before** and **after** phases. The before phase receives the activity
191
+ * input (and can modify `activityCtx.args`). The after phase receives the
192
+ * activity output as the return value of `next()`.
193
+ *
194
+ * This enables patterns like publishing activity results to an external
195
+ * system (e.g., SNS, audit log) without modifying the workflow itself.
196
+ *
197
+ * **Important:** The after-phase proxy activity calls go through the same
198
+ * interceptor chain. Guard against recursion by checking `activityCtx.activityName`
199
+ * to skip the interceptor's own calls.
200
+ *
201
+ * @example
202
+ * ```typescript
203
+ * import { Durable } from '@hotmeshio/hotmesh';
204
+ * import type { ActivityInterceptor } from '@hotmeshio/hotmesh/types/durable';
205
+ * import * as activities from './activities';
206
+ *
207
+ * // Activity interceptor that publishes results via a proxy activity
208
+ * const publishResultInterceptor: ActivityInterceptor = {
209
+ * async execute(activityCtx, workflowCtx, next) {
210
+ * try {
211
+ * // BEFORE: inspect or modify the activity input
212
+ * console.log(`Calling ${activityCtx.activityName}`, activityCtx.args);
213
+ *
214
+ * // Execute the activity (returns stored result on replay)
215
+ * const result = await next();
216
+ *
217
+ * // AFTER: use the activity output (only runs on replay,
218
+ * // once the result is available)
219
+ *
220
+ * // Guard: skip for the interceptor's own proxy calls
221
+ * if (activityCtx.activityName !== 'publishToSNS') {
222
+ * const { publishToSNS } = Durable.workflow.proxyActivities<{
223
+ * publishToSNS: (topic: string, payload: any) => Promise<void>;
224
+ * }>({
225
+ * taskQueue: 'shared-notifications',
226
+ * retryPolicy: { maximumAttempts: 3, throwOnError: true },
227
+ * });
228
+ *
229
+ * await publishToSNS('activity-results', {
230
+ * workflowId: workflowCtx.get('workflowId'),
231
+ * activityName: activityCtx.activityName,
232
+ * input: activityCtx.args,
233
+ * output: result,
234
+ * });
235
+ * }
236
+ *
237
+ * return result;
238
+ * } catch (err) {
239
+ * if (Durable.didInterrupt(err)) throw err;
240
+ * throw err;
241
+ * }
242
+ * },
243
+ * };
244
+ *
245
+ * Durable.registerActivityInterceptor(publishResultInterceptor);
246
+ * ```
247
+ *
248
+ * ## Activity Interceptor Replay Pattern
249
+ *
250
+ * Activity interceptors participate in the interruption/replay cycle:
251
+ *
252
+ * 1. **First execution**: Before-phase runs → `next()` registers the activity
253
+ * interruption and throws `DurableProxyError` → workflow pauses
254
+ * 2. **Second execution**: Before-phase replays → `next()` returns the stored
255
+ * activity result → after-phase runs → after-phase proxy call (e.g.,
256
+ * `publishToSNS`) registers its own interruption → workflow pauses
257
+ * 3. **Third execution**: Everything replays → after-phase proxy call returns
258
+ * its stored result → interceptor returns → workflow continues
186
259
  */
187
260
  class InterceptorService {
188
261
  constructor() {
@@ -42,36 +42,40 @@ exports.getProxyInterruptPayload = getProxyInterruptPayload;
42
42
  */
43
43
  function wrapActivity(activityName, options) {
44
44
  return async function (...args) {
45
+ // Increment counter first for deterministic replay ordering
45
46
  const [didRunAlready, execIndex, result] = await (0, didRun_1.didRun)('proxy');
46
- if (didRunAlready) {
47
- if (result?.$error) {
48
- if (options?.retryPolicy?.throwOnError !== false) {
49
- const code = result.$error.code;
50
- const message = result.$error.message;
51
- const stack = result.$error.stack;
52
- if (code === common_1.HMSH_CODE_DURABLE_FATAL) {
53
- throw new common_1.DurableFatalError(message, stack);
54
- }
55
- else if (code === common_1.HMSH_CODE_DURABLE_MAXED) {
56
- throw new common_1.DurableMaxedError(message, stack);
57
- }
58
- else if (code === common_1.HMSH_CODE_DURABLE_TIMEOUT) {
59
- throw new common_1.DurableTimeoutError(message, stack);
60
- }
61
- else {
62
- // For any other error code, throw a DurableFatalError to stop the workflow
63
- throw new common_1.DurableFatalError(message, stack);
47
+ const context = (0, context_1.getContext)();
48
+ const { interruptionRegistry } = context;
49
+ // Build activityCtx so interceptors can inspect/modify args
50
+ const activityCtx = { activityName, args, options };
51
+ // Core function: returns stored result on replay, or registers
52
+ // the interruption and throws on first execution. Reads args
53
+ // from activityCtx so "before" interceptors can modify them.
54
+ const coreFunction = async () => {
55
+ if (didRunAlready) {
56
+ if (result?.$error) {
57
+ if (options?.retryPolicy?.throwOnError !== false) {
58
+ const code = result.$error.code;
59
+ const message = result.$error.message;
60
+ const stack = result.$error.stack;
61
+ if (code === common_1.HMSH_CODE_DURABLE_FATAL) {
62
+ throw new common_1.DurableFatalError(message, stack);
63
+ }
64
+ else if (code === common_1.HMSH_CODE_DURABLE_MAXED) {
65
+ throw new common_1.DurableMaxedError(message, stack);
66
+ }
67
+ else if (code === common_1.HMSH_CODE_DURABLE_TIMEOUT) {
68
+ throw new common_1.DurableTimeoutError(message, stack);
69
+ }
70
+ else {
71
+ throw new common_1.DurableFatalError(message, stack);
72
+ }
64
73
  }
74
+ return result.$error;
65
75
  }
66
- return result.$error;
76
+ return result.data;
67
77
  }
68
- return result.data;
69
- }
70
- const context = (0, context_1.getContext)();
71
- const { interruptionRegistry } = context;
72
- // Core activity registration logic
73
- const executeActivity = async () => {
74
- const interruptionMessage = getProxyInterruptPayload(context, activityName, execIndex, args, options);
78
+ const interruptionMessage = getProxyInterruptPayload(context, activityName, execIndex, activityCtx.args, options);
75
79
  interruptionRegistry.push({
76
80
  code: common_1.HMSH_CODE_DURABLE_PROXY,
77
81
  type: 'DurableProxyError',
@@ -80,13 +84,13 @@ function wrapActivity(activityName, options) {
80
84
  await (0, common_1.sleepImmediate)();
81
85
  throw new common_1.DurableProxyError(interruptionMessage);
82
86
  };
83
- // Check for activity interceptors
87
+ // Run through interceptor chain if interceptors exist
84
88
  const store = common_1.asyncLocalStorage.getStore();
85
89
  const interceptorService = store?.get('activityInterceptorService');
86
90
  if (interceptorService?.activityInterceptors?.length > 0) {
87
- return await interceptorService.executeActivityChain({ activityName, args, options }, store, executeActivity);
91
+ return await interceptorService.executeActivityChain(activityCtx, store, coreFunction);
88
92
  }
89
- return await executeActivity();
93
+ return await coreFunction();
90
94
  };
91
95
  }
92
96
  exports.wrapActivity = wrapActivity;
@@ -596,15 +596,21 @@ export interface WorkflowInterceptor {
596
596
  execute(ctx: Map<string, any>, next: () => Promise<any>): Promise<any>;
597
597
  }
598
598
  /**
599
- * Registry for workflow interceptors that are executed in order
600
- * for each workflow execution
599
+ * Registry for workflow and activity interceptors that are executed in order
600
+ * for each workflow or activity execution.
601
601
  */
602
602
  export interface InterceptorRegistry {
603
603
  /**
604
- * Array of registered interceptors that will wrap workflow execution
605
- * in the order they were registered (first registered = outermost wrapper)
604
+ * Array of registered workflow interceptors that will wrap workflow execution
605
+ * in the order they were registered (first registered = outermost wrapper).
606
606
  */
607
607
  interceptors: WorkflowInterceptor[];
608
+ /**
609
+ * Array of registered activity interceptors that will wrap individual
610
+ * proxied activity calls in the order they were registered
611
+ * (first registered = outermost wrapper).
612
+ */
613
+ activityInterceptors: ActivityInterceptor[];
608
614
  }
609
615
  /**
610
616
  * Context provided to an activity interceptor, containing metadata
@@ -624,11 +630,21 @@ export interface ActivityInterceptorContext {
624
630
  * workflow methods (proxyActivities, sleepFor, waitFor, execChild, etc.)
625
631
  * are available.
626
632
  *
627
- * Activity interceptors execute BEFORE the activity registers with the
628
- * interruptionRegistry and throws. Any Durable operations called within
629
- * the interceptor will register their own interruptions first, and those
630
- * will be processed before the wrapped activity's interruption during
631
- * the replay cycle.
633
+ * Activity interceptors wrap proxied activity calls in an onion pattern,
634
+ * supporting both **before** and **after** phases:
635
+ *
636
+ * - **Before phase** (code before `await next()`): Runs before the activity
637
+ * executes. The interceptor can inspect or modify `activityCtx.args` to
638
+ * transform the activity input before it is sent.
639
+ *
640
+ * - **After phase** (code after `await next()`): Runs on replay once the
641
+ * activity result is available. The interceptor receives the activity
642
+ * output as the return value of `next()` and can inspect or transform it.
643
+ *
644
+ * On first execution, `next()` registers the activity with the interruption
645
+ * system and throws (the activity has not completed yet). On replay, `next()`
646
+ * returns the stored result and the after-phase code executes. This follows
647
+ * the same deterministic replay pattern as workflow interceptors.
632
648
  *
633
649
  * @example
634
650
  * ```typescript
@@ -653,11 +669,13 @@ export interface ActivityInterceptorContext {
653
669
  */
654
670
  export interface ActivityInterceptor {
655
671
  /**
656
- * Called before each proxied activity invocation.
672
+ * Called around each proxied activity invocation. Code before `next()`
673
+ * runs in the before phase; code after `next()` runs in the after phase
674
+ * once the activity result is available on replay.
657
675
  *
658
- * @param activityCtx - Metadata about the activity being called
676
+ * @param activityCtx - Metadata about the activity being called (args may be modified)
659
677
  * @param workflowCtx - The workflow context map (same as WorkflowInterceptor receives)
660
- * @param next - Call to proceed to the next interceptor or the actual activity registration
678
+ * @param next - Call to proceed to the next interceptor or the core activity function
661
679
  * @returns The activity result (from replay or after interruption/re-execution)
662
680
  */
663
681
  execute(activityCtx: ActivityInterceptorContext, workflowCtx: Map<string, any>, next: () => Promise<any>): Promise<any>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Permanent-Memory Workflows & AI Agents",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -110,13 +110,12 @@
110
110
  "nats": "^2.28.0",
111
111
  "openai": "^5.9.0",
112
112
  "pg": "^8.10.0",
113
- "rimraf": "^4.4.1",
113
+ "rimraf": "^6.1.3",
114
114
  "terser": "^5.37.0",
115
115
  "ts-node": "^10.9.1",
116
- "ts-node-dev": "^2.0.0",
117
116
  "typedoc": "^0.26.4",
118
117
  "typescript": "^5.0.4",
119
- "vitest": "^2.1.9"
118
+ "vitest": "^4.0.18"
120
119
  },
121
120
  "peerDependencies": {
122
121
  "nats": "^2.0.0",
File without changes