@hotmeshio/hotmesh 0.10.0 → 0.10.2

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.
@@ -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;
@@ -182,6 +182,7 @@ const KVTables = (context) => ({
182
182
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
183
183
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
184
184
  expired_at TIMESTAMP WITH TIME ZONE,
185
+ pruned_at TIMESTAMP WITH TIME ZONE,
185
186
  is_live BOOLEAN DEFAULT TRUE,
186
187
  PRIMARY KEY (id)
187
188
  ) PARTITION BY HASH (id);
@@ -214,8 +215,12 @@ const KVTables = (context) => ({
214
215
  ON ${fullTableName} (entity, status);
215
216
  `);
216
217
  await client.query(`
217
- CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_expired_at
218
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_expired_at
218
219
  ON ${fullTableName} (expired_at);
220
+ `);
221
+ await client.query(`
222
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_pruned_at
223
+ ON ${fullTableName} (pruned_at) WHERE pruned_at IS NULL;
219
224
  `);
220
225
  // Create function to update is_live flag in the schema
221
226
  await client.query(`
@@ -31,7 +31,7 @@ export interface PruneOptions {
31
31
  /**
32
32
  * If true, hard-deletes expired jobs older than the retention window.
33
33
  * FK CASCADE on `jobs_attributes` automatically removes associated
34
- * attribute rows.
34
+ * attribute rows. When `entities` is set, only matching jobs are deleted.
35
35
  * @default true
36
36
  */
37
37
  jobs?: boolean;
@@ -42,13 +42,35 @@ export interface PruneOptions {
42
42
  */
43
43
  streams?: boolean;
44
44
  /**
45
- * If true, strips execution-artifact attributes (`adata`, `hmark`,
46
- * `jmark`, `status`, `other`) from completed jobs (status = 0),
47
- * retaining only `jdata` (workflow return data) and `udata`
48
- * (user-searchable data).
45
+ * If true, strips execution-artifact attributes from completed,
46
+ * un-pruned jobs. Preserves `jdata` (return data), `udata`
47
+ * (searchable data), and `jmark` (timeline/event history for
48
+ * Temporal-compatible export). See `keepHmark` for `hmark`.
49
49
  * @default false
50
50
  */
51
51
  attributes?: boolean;
52
+ /**
53
+ * Entity allowlist. When provided, only jobs whose `entity` column
54
+ * matches one of these values are eligible for pruning/stripping.
55
+ * Jobs with `entity IS NULL` are excluded unless `pruneTransient`
56
+ * is also true.
57
+ * @default undefined (all entities)
58
+ */
59
+ entities?: string[];
60
+ /**
61
+ * If true, hard-deletes expired jobs where `entity IS NULL`
62
+ * (transient workflow runs). Must also satisfy the retention
63
+ * window (`expire`).
64
+ * @default false
65
+ */
66
+ pruneTransient?: boolean;
67
+ /**
68
+ * If true, `hmark` attributes are preserved during stripping
69
+ * (along with `jdata`, `udata`, and `jmark`). If false, `hmark`
70
+ * rows are stripped.
71
+ * @default false
72
+ */
73
+ keepHmark?: boolean;
52
74
  }
53
75
  /**
54
76
  * Result returned by `DBA.prune()`, providing deletion
@@ -61,4 +83,8 @@ export interface PruneResult {
61
83
  streams: number;
62
84
  /** Number of execution-artifact attribute rows stripped from completed jobs */
63
85
  attributes: number;
86
+ /** Number of transient (entity IS NULL) job rows hard-deleted */
87
+ transient: number;
88
+ /** Number of jobs marked as pruned (pruned_at set) */
89
+ marked: number;
64
90
  }
@@ -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>;
@@ -79,3 +79,130 @@ export interface JobExport {
79
79
  process: StringAnyType;
80
80
  status: string;
81
81
  }
82
+ export type ExportMode = 'sparse' | 'verbose';
83
+ export type WorkflowEventType = 'workflow_execution_started' | 'workflow_execution_completed' | 'workflow_execution_failed' | 'activity_task_scheduled' | 'activity_task_completed' | 'activity_task_failed' | 'child_workflow_execution_started' | 'child_workflow_execution_completed' | 'child_workflow_execution_failed' | 'timer_started' | 'timer_fired' | 'workflow_execution_signaled';
84
+ export type WorkflowEventCategory = 'workflow' | 'activity' | 'child_workflow' | 'timer' | 'signal';
85
+ export interface WorkflowExecutionStartedAttributes {
86
+ kind: 'workflow_execution_started';
87
+ workflow_type: string;
88
+ task_queue: string;
89
+ input?: any;
90
+ }
91
+ export interface WorkflowExecutionCompletedAttributes {
92
+ kind: 'workflow_execution_completed';
93
+ result?: any;
94
+ }
95
+ export interface WorkflowExecutionFailedAttributes {
96
+ kind: 'workflow_execution_failed';
97
+ failure?: string;
98
+ }
99
+ export interface ActivityTaskScheduledAttributes {
100
+ kind: 'activity_task_scheduled';
101
+ activity_type: string;
102
+ timeline_key: string;
103
+ execution_index: number;
104
+ }
105
+ export interface ActivityTaskCompletedAttributes {
106
+ kind: 'activity_task_completed';
107
+ activity_type: string;
108
+ result?: any;
109
+ scheduled_event_id?: number;
110
+ timeline_key: string;
111
+ execution_index: number;
112
+ }
113
+ export interface ActivityTaskFailedAttributes {
114
+ kind: 'activity_task_failed';
115
+ activity_type: string;
116
+ failure?: any;
117
+ scheduled_event_id?: number;
118
+ timeline_key: string;
119
+ execution_index: number;
120
+ }
121
+ export interface ChildWorkflowExecutionStartedAttributes {
122
+ kind: 'child_workflow_execution_started';
123
+ child_workflow_id: string;
124
+ awaited: boolean;
125
+ timeline_key: string;
126
+ execution_index: number;
127
+ }
128
+ export interface ChildWorkflowExecutionCompletedAttributes {
129
+ kind: 'child_workflow_execution_completed';
130
+ child_workflow_id: string;
131
+ result?: any;
132
+ initiated_event_id?: number;
133
+ timeline_key: string;
134
+ execution_index: number;
135
+ }
136
+ export interface ChildWorkflowExecutionFailedAttributes {
137
+ kind: 'child_workflow_execution_failed';
138
+ child_workflow_id: string;
139
+ failure?: any;
140
+ initiated_event_id?: number;
141
+ timeline_key: string;
142
+ execution_index: number;
143
+ }
144
+ export interface TimerStartedAttributes {
145
+ kind: 'timer_started';
146
+ duration_ms?: number;
147
+ timeline_key: string;
148
+ execution_index: number;
149
+ }
150
+ export interface TimerFiredAttributes {
151
+ kind: 'timer_fired';
152
+ timeline_key: string;
153
+ execution_index: number;
154
+ }
155
+ export interface WorkflowExecutionSignaledAttributes {
156
+ kind: 'workflow_execution_signaled';
157
+ signal_name: string;
158
+ input?: any;
159
+ timeline_key: string;
160
+ execution_index: number;
161
+ }
162
+ export type WorkflowEventAttributes = WorkflowExecutionStartedAttributes | WorkflowExecutionCompletedAttributes | WorkflowExecutionFailedAttributes | ActivityTaskScheduledAttributes | ActivityTaskCompletedAttributes | ActivityTaskFailedAttributes | ChildWorkflowExecutionStartedAttributes | ChildWorkflowExecutionCompletedAttributes | ChildWorkflowExecutionFailedAttributes | TimerStartedAttributes | TimerFiredAttributes | WorkflowExecutionSignaledAttributes;
163
+ export interface WorkflowExecutionEvent {
164
+ event_id: number;
165
+ event_type: WorkflowEventType;
166
+ category: WorkflowEventCategory;
167
+ event_time: string;
168
+ duration_ms: number | null;
169
+ is_system: boolean;
170
+ attributes: WorkflowEventAttributes;
171
+ }
172
+ export interface WorkflowExecutionSummary {
173
+ total_events: number;
174
+ activities: {
175
+ total: number;
176
+ completed: number;
177
+ failed: number;
178
+ system: number;
179
+ user: number;
180
+ };
181
+ child_workflows: {
182
+ total: number;
183
+ completed: number;
184
+ failed: number;
185
+ };
186
+ timers: number;
187
+ signals: number;
188
+ }
189
+ export type WorkflowExecutionStatus = 'running' | 'completed' | 'failed';
190
+ export interface WorkflowExecution {
191
+ workflow_id: string;
192
+ workflow_type: string;
193
+ task_queue: string;
194
+ status: WorkflowExecutionStatus;
195
+ start_time: string | null;
196
+ close_time: string | null;
197
+ duration_ms: number | null;
198
+ result: any;
199
+ events: WorkflowExecutionEvent[];
200
+ summary: WorkflowExecutionSummary;
201
+ children?: WorkflowExecution[];
202
+ }
203
+ export interface ExecutionExportOptions {
204
+ mode?: ExportMode;
205
+ exclude_system?: boolean;
206
+ omit_results?: boolean;
207
+ max_depth?: number;
208
+ }
@@ -6,7 +6,7 @@ export { CollationFaultType, CollationStage } from './collator';
6
6
  export { ActivityConfig, ActivityInterceptor, ActivityInterceptorContext, ActivityWorkflowDataType, ChildResponseType, ClientConfig, ClientWorkflow, ContextType, Connection, ProxyResponseType, ProxyType, Registry, SignalOptions, FindJobsOptions, FindOptions, FindWhereOptions, FindWhereQuery, HookOptions, SearchResults, WorkflowConfig, WorkerConfig, WorkerOptions, WorkflowContext, WorkflowSearchOptions, WorkflowSearchSchema, WorkflowDataType, WorkflowOptions, WorkflowInterceptor, InterceptorRegistry, } from './durable';
7
7
  export { PruneOptions, PruneResult, } from './dba';
8
8
  export { DurableChildErrorType, DurableProxyErrorType, DurableSleepErrorType, DurableWaitForAllErrorType, DurableWaitForErrorType, } from './error';
9
- export { ActivityAction, DependencyExport, DurableJobExport, ExportCycles, ExportItem, ExportOptions, ExportTransitions, JobAction, JobExport, JobActionExport, JobTimeline, } from './exporter';
9
+ export { ActivityAction, DependencyExport, DurableJobExport, ExecutionExportOptions, ExportCycles, ExportItem, ExportMode, ExportOptions, ExportTransitions, JobAction, JobExport, JobActionExport, JobTimeline, WorkflowEventAttributes, WorkflowEventCategory, WorkflowEventType, WorkflowExecution, WorkflowExecutionEvent, WorkflowExecutionStatus, WorkflowExecutionSummary, } from './exporter';
10
10
  export { HookCondition, HookConditions, HookGate, HookInterface, HookRule, HookRules, HookSignal, } from './hook';
11
11
  export { HotMesh, HotMeshEngine, HotMeshWorker, HotMeshSettings, HotMeshApp, HotMeshApps, HotMeshConfig, HotMeshManifest, HotMeshGraph, KeyType, KeyStoreParams, ScoutType, } from './hotmesh';
12
12
  export { ILogger, LogLevel } from './logger';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Permanent-Memory Workflows & AI Agents",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -46,6 +46,8 @@
46
46
  "test:durable:sleep": "vitest run tests/durable/sleep/postgres.test.ts",
47
47
  "test:durable:signal": "vitest run tests/durable/signal/postgres.test.ts",
48
48
  "test:durable:unknown": "vitest run tests/durable/unknown/postgres.test.ts",
49
+ "test:durable:exporter": "vitest run tests/durable/exporter/exporter.test.ts",
50
+ "test:durable:exporter:debug": "EXPORT_DEBUG=1 HMSH_LOGLEVEL=error vitest run tests/durable/basic/postgres.test.ts",
49
51
  "test:dba": "vitest run tests/dba",
50
52
  "test:cycle": "vitest run tests/functional/cycle",
51
53
  "test:functional": "vitest run tests/functional",
@@ -110,13 +112,12 @@
110
112
  "nats": "^2.28.0",
111
113
  "openai": "^5.9.0",
112
114
  "pg": "^8.10.0",
113
- "rimraf": "^4.4.1",
115
+ "rimraf": "^6.1.3",
114
116
  "terser": "^5.37.0",
115
117
  "ts-node": "^10.9.1",
116
- "ts-node-dev": "^2.0.0",
117
118
  "typedoc": "^0.26.4",
118
119
  "typescript": "^5.0.4",
119
- "vitest": "^2.1.9"
120
+ "vitest": "^4.0.18"
120
121
  },
121
122
  "peerDependencies": {
122
123
  "nats": "^2.0.0",
File without changes