@hotmeshio/hotmesh 0.14.3 → 0.14.5

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 (31) hide show
  1. package/build/modules/enums.d.ts +6 -0
  2. package/build/modules/enums.js +8 -2
  3. package/build/package.json +4 -3
  4. package/build/services/activities/hook.d.ts +10 -1
  5. package/build/services/activities/hook.js +45 -6
  6. package/build/services/dba/index.d.ts +1 -0
  7. package/build/services/dba/index.js +20 -3
  8. package/build/services/durable/client.js +13 -3
  9. package/build/services/durable/handle.d.ts +8 -1
  10. package/build/services/durable/handle.js +9 -1
  11. package/build/services/durable/worker.js +4 -0
  12. package/build/services/durable/workflow/signal.d.ts +1 -1
  13. package/build/services/durable/workflow/signal.js +2 -1
  14. package/build/services/mapper/index.d.ts +57 -2
  15. package/build/services/mapper/index.js +57 -2
  16. package/build/services/pipe/index.d.ts +444 -10
  17. package/build/services/pipe/index.js +444 -10
  18. package/build/services/store/index.d.ts +15 -2
  19. package/build/services/store/providers/postgres/kvtables.d.ts +1 -0
  20. package/build/services/store/providers/postgres/kvtables.js +46 -1
  21. package/build/services/store/providers/postgres/postgres.d.ts +25 -2
  22. package/build/services/store/providers/postgres/postgres.js +121 -4
  23. package/build/services/stream/registry.d.ts +1 -0
  24. package/build/services/stream/registry.js +12 -8
  25. package/build/services/task/index.d.ts +4 -1
  26. package/build/services/task/index.js +34 -6
  27. package/build/services/worker/index.js +2 -0
  28. package/build/types/dba.d.ts +11 -0
  29. package/build/types/hotmesh.d.ts +8 -0
  30. package/package.json +4 -3
  31. package/vitest.config.mts +1 -1
@@ -55,6 +55,12 @@ export declare const HMSH_TELEMETRY: "debug" | "info";
55
55
  * Default cleanup time for signal in the db when its associated job is completed.
56
56
  */
57
57
  export declare const HMSH_SIGNAL_EXPIRE = 3600;
58
+ /**
59
+ * Default TTL for pending signals (signals that arrived before the hook registered).
60
+ * The signaler can override this via the `$expire` field in the signal data
61
+ * using a natural-language duration (e.g., '1h', '24h').
62
+ */
63
+ export declare const HMSH_PENDING_SIGNAL_EXPIRE = 600;
58
64
  export declare const HMSH_CODE_SUCCESS = 200;
59
65
  export declare const HMSH_CODE_PENDING = 202;
60
66
  export declare const HMSH_CODE_NOTFOUND = 404;
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HMSH_GUID_SIZE = exports.HMSH_ROUTER_SCOUT_INTERVAL_MS = exports.HMSH_ROUTER_SCOUT_INTERVAL_SECONDS = exports.HMSH_SCOUT_INTERVAL_SECONDS = exports.HMSH_FIDELITY_SECONDS = exports.HMSH_EXPIRE_DURATION = exports.HMSH_XPENDING_COUNT = exports.HMSH_XCLAIM_COUNT = exports.HMSH_XCLAIM_DELAY_MS = exports.HMSH_BLOCK_TIME_MS = exports.HMSH_DURABLE_INITIAL_INTERVAL = exports.HMSH_DURABLE_EXP_BACKOFF = exports.HMSH_DURABLE_MAX_INTERVAL = exports.HMSH_DURABLE_MAX_ATTEMPTS = exports.HMSH_GRADUATED_INTERVAL_MS = exports.HMSH_MAX_TIMEOUT_MS = exports.HMSH_POISON_MESSAGE_THRESHOLD = exports.HMSH_MAX_RETRIES = exports.MAX_DELAY = exports.MAX_STREAM_RETRIES = exports.INITIAL_STREAM_BACKOFF = exports.MAX_STREAM_BACKOFF = exports.HMSH_EXPIRE_JOB_SECONDS = exports.HMSH_OTT_WAIT_TIME = exports.HMSH_DEPLOYMENT_PAUSE = exports.HMSH_DEPLOYMENT_DELAY = exports.HMSH_ACTIVATION_MAX_RETRY = exports.HMSH_QUORUM_DELAY_MS = exports.HMSH_QUORUM_ROLLCALL_CYCLES = exports.HMSH_STATUS_UNKNOWN = exports.HMSH_CODE_DURABLE_RETRYABLE = exports.HMSH_CODE_DURABLE_FATAL = exports.HMSH_CODE_DURABLE_MAXED = exports.HMSH_CODE_DURABLE_TIMEOUT = exports.HMSH_CODE_DURABLE_WAIT = exports.HMSH_CODE_DURABLE_CONTINUE = exports.HMSH_CODE_DURABLE_PROXY = exports.HMSH_CODE_DURABLE_CHILD = exports.HMSH_CODE_DURABLE_ALL = exports.HMSH_CODE_DURABLE_SLEEP = exports.HMSH_CODE_UNACKED = exports.HMSH_CODE_TIMEOUT = exports.HMSH_CODE_UNKNOWN = exports.HMSH_CODE_INTERRUPT = exports.HMSH_CODE_NOTFOUND = exports.HMSH_CODE_PENDING = exports.HMSH_CODE_SUCCESS = exports.HMSH_SIGNAL_EXPIRE = exports.HMSH_TELEMETRY = exports.HMSH_LOGLEVEL = void 0;
4
- exports.HMSH_ROUTER_POLL_FALLBACK_INTERVAL = exports.HMSH_NOTIFY_PAYLOAD_LIMIT = exports.DEFAULT_TASK_QUEUE = void 0;
3
+ exports.HMSH_ROUTER_SCOUT_INTERVAL_MS = exports.HMSH_ROUTER_SCOUT_INTERVAL_SECONDS = exports.HMSH_SCOUT_INTERVAL_SECONDS = exports.HMSH_FIDELITY_SECONDS = exports.HMSH_EXPIRE_DURATION = exports.HMSH_XPENDING_COUNT = exports.HMSH_XCLAIM_COUNT = exports.HMSH_XCLAIM_DELAY_MS = exports.HMSH_BLOCK_TIME_MS = exports.HMSH_DURABLE_INITIAL_INTERVAL = exports.HMSH_DURABLE_EXP_BACKOFF = exports.HMSH_DURABLE_MAX_INTERVAL = exports.HMSH_DURABLE_MAX_ATTEMPTS = exports.HMSH_GRADUATED_INTERVAL_MS = exports.HMSH_MAX_TIMEOUT_MS = exports.HMSH_POISON_MESSAGE_THRESHOLD = exports.HMSH_MAX_RETRIES = exports.MAX_DELAY = exports.MAX_STREAM_RETRIES = exports.INITIAL_STREAM_BACKOFF = exports.MAX_STREAM_BACKOFF = exports.HMSH_EXPIRE_JOB_SECONDS = exports.HMSH_OTT_WAIT_TIME = exports.HMSH_DEPLOYMENT_PAUSE = exports.HMSH_DEPLOYMENT_DELAY = exports.HMSH_ACTIVATION_MAX_RETRY = exports.HMSH_QUORUM_DELAY_MS = exports.HMSH_QUORUM_ROLLCALL_CYCLES = exports.HMSH_STATUS_UNKNOWN = exports.HMSH_CODE_DURABLE_RETRYABLE = exports.HMSH_CODE_DURABLE_FATAL = exports.HMSH_CODE_DURABLE_MAXED = exports.HMSH_CODE_DURABLE_TIMEOUT = exports.HMSH_CODE_DURABLE_WAIT = exports.HMSH_CODE_DURABLE_CONTINUE = exports.HMSH_CODE_DURABLE_PROXY = exports.HMSH_CODE_DURABLE_CHILD = exports.HMSH_CODE_DURABLE_ALL = exports.HMSH_CODE_DURABLE_SLEEP = exports.HMSH_CODE_UNACKED = exports.HMSH_CODE_TIMEOUT = exports.HMSH_CODE_UNKNOWN = exports.HMSH_CODE_INTERRUPT = exports.HMSH_CODE_NOTFOUND = exports.HMSH_CODE_PENDING = exports.HMSH_CODE_SUCCESS = exports.HMSH_PENDING_SIGNAL_EXPIRE = exports.HMSH_SIGNAL_EXPIRE = exports.HMSH_TELEMETRY = exports.HMSH_LOGLEVEL = void 0;
4
+ exports.HMSH_ROUTER_POLL_FALLBACK_INTERVAL = exports.HMSH_NOTIFY_PAYLOAD_LIMIT = exports.DEFAULT_TASK_QUEUE = exports.HMSH_GUID_SIZE = void 0;
5
5
  /**
6
6
  * Determines the log level for the application. The default is 'info'.
7
7
  */
@@ -58,6 +58,12 @@ exports.HMSH_TELEMETRY = process.env.HMSH_TELEMETRY || 'info';
58
58
  * Default cleanup time for signal in the db when its associated job is completed.
59
59
  */
60
60
  exports.HMSH_SIGNAL_EXPIRE = 3600; //seconds
61
+ /**
62
+ * Default TTL for pending signals (signals that arrived before the hook registered).
63
+ * The signaler can override this via the `$expire` field in the signal data
64
+ * using a natural-language duration (e.g., '1h', '24h').
65
+ */
66
+ exports.HMSH_PENDING_SIGNAL_EXPIRE = 600; //seconds (10 minutes)
61
67
  // HOTMESH STATUS CODES
62
68
  exports.HMSH_CODE_SUCCESS = 200;
63
69
  exports.HMSH_CODE_PENDING = 202;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -14,8 +14,8 @@
14
14
  "obfuscate": "ts-node scripts/obfuscate.ts",
15
15
  "clean-build": "npm run clean && npm run build",
16
16
  "clean-build-obfuscate": "npm run clean-build && npm run obfuscate",
17
- "docs": "typedoc",
18
- "docs:clean": "rimraf ./docs/hotmesh && typedoc",
17
+ "docs": "typedoc && cp -R docs/hotmesh/* docs/ && rm -rf docs/hotmesh",
18
+ "docs:clean": "rimraf ./docs/hotmesh && typedoc && cp -R docs/hotmesh/* docs/ && rm -rf docs/hotmesh",
19
19
  "lint": "eslint . --ext .ts",
20
20
  "lint:fix": "eslint . --fix --ext .ts",
21
21
  "start": "ts-node src/index.ts",
@@ -47,6 +47,7 @@
47
47
  "test:durable:retrypolicy": "vitest run tests/durable/retry-policy",
48
48
  "test:durable:sleep": "vitest run tests/durable/sleep/postgres.test.ts",
49
49
  "test:durable:signal": "vitest run tests/durable/signal/postgres.test.ts",
50
+ "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",
50
51
  "test:durable:unknown": "vitest run tests/durable/unknown/postgres.test.ts",
51
52
  "test:durable:exporter": "HMSH_LOGLEVEL=info vitest run tests/durable/exporter",
52
53
  "test:durable:exporter:debug": "EXPORT_DEBUG=1 HMSH_LOGLEVEL=error vitest run tests/durable/basic/postgres.test.ts",
@@ -152,9 +152,18 @@ declare class Hook extends Activity {
152
152
  isConfiguredAsHook(): boolean;
153
153
  doesHook(): boolean;
154
154
  doHook(telemetry: TelemetryService): Promise<void>;
155
+ /**
156
+ * Re-publishes a pending signal as a WEBHOOK stream message so the
157
+ * normal leg2 dispatch path processes it. Called when leg1's
158
+ * setHookSignal atomically detected and consumed a pending signal.
159
+ */
160
+ private redeliverPendingSignal;
155
161
  doPassThrough(telemetry: TelemetryService): Promise<void>;
156
162
  getHookRule(topic: string): Promise<HookRule | undefined>;
157
- registerHook(transaction?: ProviderTransaction): Promise<string | void>;
163
+ registerHook(transaction?: ProviderTransaction): Promise<{
164
+ jobId?: string;
165
+ pending?: string;
166
+ } | void>;
158
167
  processWebHookEvent(status?: StreamStatus, code?: StreamCode): Promise<JobStatus | void>;
159
168
  processTimeHookEvent(jobId: string): Promise<JobStatus | void>;
160
169
  }
@@ -6,6 +6,7 @@ const pipe_1 = require("../pipe");
6
6
  const task_1 = require("../task");
7
7
  const telemetry_1 = require("../telemetry");
8
8
  const stream_1 = require("../../types/stream");
9
+ const utils_1 = require("../../modules/utils");
9
10
  const activity_1 = require("./activity");
10
11
  /**
11
12
  * A versatile pause/resume activity that supports three distinct patterns:
@@ -203,7 +204,7 @@ class Hook extends activity_1.Activity {
203
204
  }
204
205
  async doHook(telemetry) {
205
206
  const transaction = this.store.transact();
206
- await this.registerHook(transaction);
207
+ const hookResult = await this.registerHook(transaction);
207
208
  this.mapOutputData();
208
209
  this.mapJobData();
209
210
  await this.setState(transaction);
@@ -211,6 +212,38 @@ class Hook extends activity_1.Activity {
211
212
  await this.setStatus(0, transaction);
212
213
  await transaction.exec();
213
214
  telemetry.mapActivityAttributes();
215
+ //if a pending signal was detected (signal arrived before hook
216
+ //registered), re-publish the WEBHOOK so leg2 processes it
217
+ //now that the hook signal is committed and state is saved
218
+ if (hookResult && hookResult.pending) {
219
+ await this.redeliverPendingSignal(hookResult.pending);
220
+ }
221
+ }
222
+ /**
223
+ * Re-publishes a pending signal as a WEBHOOK stream message so the
224
+ * normal leg2 dispatch path processes it. Called when leg1's
225
+ * setHookSignal atomically detected and consumed a pending signal.
226
+ */
227
+ async redeliverPendingSignal(pendingJson) {
228
+ const data = JSON.parse(pendingJson);
229
+ const hookRule = await this.getHookRule(this.config.hook.topic);
230
+ this.logger.warn('hook-pending-signal-redelivery', {
231
+ topic: this.config.hook.topic,
232
+ aid: hookRule?.to || this.metadata.aid,
233
+ jid: this.context.metadata.jid,
234
+ });
235
+ const streamData = {
236
+ type: stream_1.StreamDataType.WEBHOOK,
237
+ status: stream_1.StreamStatus.SUCCESS,
238
+ code: 200,
239
+ metadata: {
240
+ guid: (0, utils_1.guid)(),
241
+ aid: hookRule?.to || this.metadata.aid,
242
+ topic: this.config.hook.topic,
243
+ },
244
+ data,
245
+ };
246
+ await this.engine.router?.publishMessage(null, streamData);
214
247
  }
215
248
  async doPassThrough(telemetry) {
216
249
  this.adjacencyList = await this.filterAdjacent();
@@ -225,19 +258,25 @@ class Hook extends activity_1.Activity {
225
258
  return rules?.[topic]?.[0];
226
259
  }
227
260
  async registerHook(transaction) {
228
- let result;
261
+ let jobId;
262
+ let pending;
229
263
  if (this.config.hook?.topic) {
230
- result = await this.engine.taskService.registerWebHook(this.config.hook.topic, this.context, this.resolveDad(), this.context.metadata.expire, transaction);
264
+ //hook signal is set standalone (not in the transaction) so the
265
+ //single CTE query can atomically detect a pending signal collision
266
+ const hookResult = await this.engine.taskService.registerWebHook(this.config.hook.topic, this.context, this.resolveDad(), this.context.metadata.expire);
267
+ jobId = hookResult.jobId;
268
+ pending = hookResult.pending;
231
269
  }
232
270
  if (this.config.sleep) {
233
271
  const duration = pipe_1.Pipe.resolve(this.config.sleep, this.context);
234
272
  if (!isNaN(duration) && Number(duration) > 0) {
235
273
  await this.engine.taskService.registerTimeHook(this.context.metadata.jid, this.context.metadata.gid, `${this.metadata.aid}${this.metadata.dad || ''}`, 'sleep', duration, this.metadata.dad || '', transaction);
236
- if (!result)
237
- result = this.context.metadata.jid;
274
+ if (!jobId)
275
+ jobId = this.context.metadata.jid;
238
276
  }
239
277
  }
240
- return result;
278
+ if (jobId)
279
+ return { jobId, pending };
241
280
  }
242
281
  async processWebHookEvent(status = stream_1.StreamStatus.SUCCESS, code = 200) {
243
282
  this.logger.debug('hook-process-web-hook-event', {
@@ -14,6 +14,7 @@ import { PostgresClientType } from '../../types/postgres';
14
14
  * | `{appId}.jobs_attributes` | Execution artifacts (`adata`, `hmark`, `status`, `other`) that are only needed during workflow execution |
15
15
  * | `{appId}.engine_streams` | Processed engine stream messages with `expired_at` set |
16
16
  * | `{appId}.worker_streams` | Processed worker stream messages with `expired_at` set |
17
+ * | `{appId}.signal_registry` | Consumed hook signals and stale pending signals with `expiry` set |
17
18
  *
18
19
  * The `DBA` service addresses this with two methods:
19
20
  *
@@ -17,6 +17,7 @@ const postgres_1 = require("../connector/providers/postgres");
17
17
  * | `{appId}.jobs_attributes` | Execution artifacts (`adata`, `hmark`, `status`, `other`) that are only needed during workflow execution |
18
18
  * | `{appId}.engine_streams` | Processed engine stream messages with `expired_at` set |
19
19
  * | `{appId}.worker_streams` | Processed worker stream messages with `expired_at` set |
20
+ * | `{appId}.signal_registry` | Consumed hook signals and stale pending signals with `expiry` set |
20
21
  *
21
22
  * The `DBA` service addresses this with two methods:
22
23
  *
@@ -186,7 +187,8 @@ class DBA {
186
187
  prune_engine_streams BOOLEAN DEFAULT NULL,
187
188
  prune_worker_streams BOOLEAN DEFAULT NULL,
188
189
  engine_streams_retention INTERVAL DEFAULT NULL,
189
- worker_streams_retention INTERVAL DEFAULT NULL
190
+ worker_streams_retention INTERVAL DEFAULT NULL,
191
+ prune_signals BOOLEAN DEFAULT TRUE
190
192
  )
191
193
  RETURNS TABLE(
192
194
  deleted_jobs BIGINT,
@@ -195,7 +197,8 @@ class DBA {
195
197
  deleted_worker_streams BIGINT,
196
198
  stripped_attributes BIGINT,
197
199
  deleted_transient BIGINT,
198
- marked_pruned BIGINT
200
+ marked_pruned BIGINT,
201
+ deleted_signals BIGINT
199
202
  )
200
203
  LANGUAGE plpgsql
201
204
  AS $$
@@ -206,6 +209,7 @@ class DBA {
206
209
  v_stripped_attributes BIGINT := 0;
207
210
  v_deleted_transient BIGINT := 0;
208
211
  v_marked_pruned BIGINT := 0;
212
+ v_deleted_signals BIGINT := 0;
209
213
  v_do_engine BOOLEAN;
210
214
  v_do_worker BOOLEAN;
211
215
  v_engine_retention INTERVAL;
@@ -287,6 +291,15 @@ class DBA {
287
291
  GET DIAGNOSTICS v_marked_pruned = ROW_COUNT;
288
292
  END IF;
289
293
 
294
+ -- 6. Hard-delete expired signal_registry rows.
295
+ -- Includes consumed hook signals and stale pending signals.
296
+ IF prune_signals THEN
297
+ DELETE FROM ${schema}.signal_registry
298
+ WHERE expiry IS NOT NULL
299
+ AND expiry <= NOW();
300
+ GET DIAGNOSTICS v_deleted_signals = ROW_COUNT;
301
+ END IF;
302
+
290
303
  deleted_jobs := v_deleted_jobs;
291
304
  deleted_streams := v_deleted_engine_streams + v_deleted_worker_streams;
292
305
  deleted_engine_streams := v_deleted_engine_streams;
@@ -294,6 +307,7 @@ class DBA {
294
307
  stripped_attributes := v_stripped_attributes;
295
308
  deleted_transient := v_deleted_transient;
296
309
  marked_pruned := v_marked_pruned;
310
+ deleted_signals := v_deleted_signals;
297
311
  RETURN NEXT;
298
312
  END;
299
313
  $$;
@@ -391,12 +405,14 @@ class DBA {
391
405
  const workerStreams = options.workerStreams ?? null;
392
406
  const engineStreamsExpire = options.engineStreamsExpire ?? null;
393
407
  const workerStreamsExpire = options.workerStreamsExpire ?? null;
408
+ const signals = options.signals ?? true;
394
409
  await DBA.deploy(options.connection, options.appId);
395
410
  const { client, release } = await DBA.getClient(options.connection);
396
411
  try {
397
- const result = await client.query(`SELECT * FROM ${schema}.prune($1::interval, $2::boolean, $3::boolean, $4::boolean, $5::text[], $6::boolean, $7::boolean, $8::boolean, $9::boolean, $10::interval, $11::interval)`, [
412
+ const result = await client.query(`SELECT * FROM ${schema}.prune($1::interval, $2::boolean, $3::boolean, $4::boolean, $5::text[], $6::boolean, $7::boolean, $8::boolean, $9::boolean, $10::interval, $11::interval, $12::boolean)`, [
398
413
  expire, jobs, streams, attributes, entities, pruneTransient, keepHmark,
399
414
  engineStreams, workerStreams, engineStreamsExpire, workerStreamsExpire,
415
+ signals,
400
416
  ]);
401
417
  const row = result.rows[0];
402
418
  return {
@@ -407,6 +423,7 @@ class DBA {
407
423
  attributes: Number(row.stripped_attributes),
408
424
  transient: Number(row.deleted_transient),
409
425
  marked: Number(row.marked_pruned),
426
+ signals: Number(row.deleted_signals),
410
427
  };
411
428
  }
412
429
  finally {
@@ -156,11 +156,21 @@ class ClientService {
156
156
  return new handle_1.WorkflowHandleService(hotMeshClient, workflowTopic, jobId);
157
157
  },
158
158
  /**
159
- * Sends a message payload to a running workflow that is paused and awaiting the signal
159
+ * Sends a message payload to a running workflow that is paused and awaiting the signal.
160
+ *
161
+ * If the signal arrives before the workflow has registered its hook
162
+ * (race condition under load), it is buffered as a pending signal
163
+ * for up to `expire` (default 10 minutes). Use a longer duration
164
+ * when signaling "early on purpose" (e.g., depositing a payload
165
+ * hours before the workflow starts).
160
166
  */
161
- signal: async (signalId, data, namespace) => {
167
+ signal: async (signalId, data, namespace, expire) => {
162
168
  const topic = `${namespace ?? factory_1.APP_ID}.wfs.signal`;
163
- return await (await this.getHotMeshClient(topic, namespace)).signal(topic, { id: signalId, data });
169
+ return await (await this.getHotMeshClient(topic, namespace)).signal(topic, {
170
+ id: signalId,
171
+ data,
172
+ ...(expire ? { $expire: expire } : {}),
173
+ });
164
174
  },
165
175
  /**
166
176
  * Spawns an a new, isolated execution cycle within the same job.
@@ -57,10 +57,17 @@ export declare class WorkflowHandleService {
57
57
  * on `Durable.workflow.condition(signalId)`, it resumes with the
58
58
  * provided data.
59
59
  *
60
+ * If the signal arrives before the workflow has registered its hook
61
+ * (race condition under load), it is buffered as a pending signal
62
+ * for up to `expire` (default 10 minutes). Use a longer duration
63
+ * when signaling "early on purpose" (e.g., depositing a payload
64
+ * hours before the workflow starts).
65
+ *
60
66
  * @param signalId - Matches the `signalId` passed to `condition()`.
61
67
  * @param data - Payload delivered to the waiting workflow.
68
+ * @param expire - Optional pending signal TTL (e.g., '1h', '30d'). Default '10m'.
62
69
  */
63
- signal(signalId: string, data: Record<any, any>): Promise<void>;
70
+ signal(signalId: string, data: Record<any, any>, expire?: string): Promise<void>;
64
71
  /**
65
72
  * Returns the current workflow state. For a completed workflow this
66
73
  * is the final output; for a running workflow it reflects the latest
@@ -58,13 +58,21 @@ class WorkflowHandleService {
58
58
  * on `Durable.workflow.condition(signalId)`, it resumes with the
59
59
  * provided data.
60
60
  *
61
+ * If the signal arrives before the workflow has registered its hook
62
+ * (race condition under load), it is buffered as a pending signal
63
+ * for up to `expire` (default 10 minutes). Use a longer duration
64
+ * when signaling "early on purpose" (e.g., depositing a payload
65
+ * hours before the workflow starts).
66
+ *
61
67
  * @param signalId - Matches the `signalId` passed to `condition()`.
62
68
  * @param data - Payload delivered to the waiting workflow.
69
+ * @param expire - Optional pending signal TTL (e.g., '1h', '30d'). Default '10m'.
63
70
  */
64
- async signal(signalId, data) {
71
+ async signal(signalId, data, expire) {
65
72
  await this.hotMesh.signal(`${this.hotMesh.appId}.wfs.signal`, {
66
73
  id: signalId,
67
74
  data,
75
+ ...(expire ? { $expire: expire } : {}),
68
76
  });
69
77
  }
70
78
  /**
@@ -516,10 +516,12 @@ class WorkerService {
516
516
  if (WorkerService.instances.has(targetTopic)) {
517
517
  return await WorkerService.instances.get(targetTopic);
518
518
  }
519
+ const readonly = providerConfig.readonly ?? false;
519
520
  const workerEntry = {
520
521
  topic: activityTopic,
521
522
  connection: providerConfig,
522
523
  callback: this.wrapActivityFunctions().bind(this),
524
+ readonly,
523
525
  };
524
526
  if (config.workerCredentials) {
525
527
  workerEntry.workerCredentials = config.workerCredentials;
@@ -644,10 +646,12 @@ class WorkerService {
644
646
  const targetNamespace = config?.namespace ?? factory_1.APP_ID;
645
647
  const optionsHash = WorkerService.hashOptions(config?.connection);
646
648
  const targetTopic = `${optionsHash}.${targetNamespace}.${workflowTopic}`;
649
+ const readonly = providerConfig.readonly ?? false;
647
650
  const workerEntry = {
648
651
  topic: taskQueue,
649
652
  workflowName: workflowFunctionName,
650
653
  connection: providerConfig,
654
+ readonly,
651
655
  callback: this.wrapWorkflowFunction(workflowFunction, workflowTopic, workflowFunctionName, config).bind(this),
652
656
  };
653
657
  if (config.workerCredentials) {
@@ -55,4 +55,4 @@
55
55
  * @param {Record<any, any>} data - The payload to deliver to the waiting workflow.
56
56
  * @returns {Promise<string>} The resulting hook/stream ID.
57
57
  */
58
- export declare function signal(signalId: string, data: Record<any, any>): Promise<string>;
58
+ export declare function signal(signalId: string, data: Record<any, any>, expire?: string): Promise<string>;
@@ -60,7 +60,7 @@ const isSideEffectAllowed_1 = require("./isSideEffectAllowed");
60
60
  * @param {Record<any, any>} data - The payload to deliver to the waiting workflow.
61
61
  * @returns {Promise<string>} The resulting hook/stream ID.
62
62
  */
63
- async function signal(signalId, data) {
63
+ async function signal(signalId, data, expire) {
64
64
  const store = common_1.asyncLocalStorage.getStore();
65
65
  const workflowTopic = store.get('workflowTopic');
66
66
  const connection = store.get('connection');
@@ -73,6 +73,7 @@ async function signal(signalId, data) {
73
73
  return await hotMeshClient.signal(`${namespace}.wfs.signal`, {
74
74
  id: signalId,
75
75
  data,
76
+ ...(expire ? { $expire: expire } : {}),
76
77
  });
77
78
  }
78
79
  }
@@ -1,10 +1,42 @@
1
1
  import { JobState } from '../../types/job';
2
2
  import { TransitionRule } from '../../types/transition';
3
3
  import { StreamCode } from '../../types';
4
+ /**
5
+ * Evaluates and transforms data-mapping rules against live job state.
6
+ *
7
+ * @remarks
8
+ * MapperService is the bridge between a workflow's declarative mapping
9
+ * rules (including `@pipe` expressions) and the runtime job data. It
10
+ * recursively walks a rule tree, delegating leaf-level resolution to
11
+ * the {@link Pipe} engine. Static helpers such as {@link evaluate}
12
+ * also power transition-condition checks during workflow execution.
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const mapper = new MapperService(
17
+ * { greeting: '{data.name}', score: { '@pipe': [['{data.x}', '{data.y}'], ['{@math.add}']] } },
18
+ * jobState,
19
+ * );
20
+ * const result = mapper.mapRules();
21
+ * // => { greeting: 'Alice', score: 7 }
22
+ * ```
23
+ */
4
24
  declare class MapperService {
5
25
  private rules;
6
26
  private data;
27
+ /**
28
+ * @param rules - The mapping rule tree to evaluate. May contain
29
+ * literal values, `{data.*}` references, or nested `@pipe` objects.
30
+ * @param data - The current {@link JobState} used to resolve references.
31
+ */
7
32
  constructor(rules: Record<string, unknown>, data: JobState);
33
+ /**
34
+ * Recursively resolves every rule in the tree against the current job
35
+ * state and returns a fully-evaluated copy.
36
+ *
37
+ * @returns A plain object mirroring the rule structure with all
38
+ * expressions replaced by their resolved values.
39
+ */
8
40
  mapRules(): Record<string, unknown>;
9
41
  private traverseRules;
10
42
  /**
@@ -20,8 +52,31 @@ declare class MapperService {
20
52
  */
21
53
  private resolve;
22
54
  /**
23
- * Evaluates a transition rule against the current job state and incoming Stream message
24
- * to determine which (if any) transition should be taken.
55
+ * Evaluates a transition rule against the current job state and an
56
+ * incoming Stream message code to decide whether the transition fires.
57
+ *
58
+ * @remarks
59
+ * Supports both simple boolean rules (`true` / `false`) and compound
60
+ * match rules with optional AND / OR gating. When the rule includes
61
+ * a `match` array, each entry's `actual` expression is resolved via
62
+ * {@link Pipe.resolve} and compared to the `expected` value.
63
+ *
64
+ * @param transitionRule - A boolean shorthand or a {@link TransitionRule}
65
+ * containing `code`, optional `gate`, and optional `match` conditions.
66
+ * @param context - The current {@link JobState} used to resolve
67
+ * `actual` expressions inside match entries.
68
+ * @param code - The {@link StreamCode} returned by the preceding
69
+ * activity (e.g. `200`).
70
+ * @returns `true` if the transition should be taken, `false` otherwise.
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * const shouldTransition = MapperService.evaluate(
75
+ * { code: 200, match: [{ expected: true, actual: '{data.isReady}' }] },
76
+ * jobState,
77
+ * 200,
78
+ * );
79
+ * ```
25
80
  */
26
81
  static evaluate(transitionRule: TransitionRule | boolean, context: JobState, code: StreamCode): boolean;
27
82
  }
@@ -2,11 +2,43 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MapperService = void 0;
4
4
  const pipe_1 = require("../pipe");
5
+ /**
6
+ * Evaluates and transforms data-mapping rules against live job state.
7
+ *
8
+ * @remarks
9
+ * MapperService is the bridge between a workflow's declarative mapping
10
+ * rules (including `@pipe` expressions) and the runtime job data. It
11
+ * recursively walks a rule tree, delegating leaf-level resolution to
12
+ * the {@link Pipe} engine. Static helpers such as {@link evaluate}
13
+ * also power transition-condition checks during workflow execution.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const mapper = new MapperService(
18
+ * { greeting: '{data.name}', score: { '@pipe': [['{data.x}', '{data.y}'], ['{@math.add}']] } },
19
+ * jobState,
20
+ * );
21
+ * const result = mapper.mapRules();
22
+ * // => { greeting: 'Alice', score: 7 }
23
+ * ```
24
+ */
5
25
  class MapperService {
26
+ /**
27
+ * @param rules - The mapping rule tree to evaluate. May contain
28
+ * literal values, `{data.*}` references, or nested `@pipe` objects.
29
+ * @param data - The current {@link JobState} used to resolve references.
30
+ */
6
31
  constructor(rules, data) {
7
32
  this.rules = rules;
8
33
  this.data = data;
9
34
  }
35
+ /**
36
+ * Recursively resolves every rule in the tree against the current job
37
+ * state and returns a fully-evaluated copy.
38
+ *
39
+ * @returns A plain object mirroring the rule structure with all
40
+ * expressions replaced by their resolved values.
41
+ */
10
42
  mapRules() {
11
43
  return this.traverseRules(this.rules);
12
44
  }
@@ -46,8 +78,31 @@ class MapperService {
46
78
  return pipe.process();
47
79
  }
48
80
  /**
49
- * Evaluates a transition rule against the current job state and incoming Stream message
50
- * to determine which (if any) transition should be taken.
81
+ * Evaluates a transition rule against the current job state and an
82
+ * incoming Stream message code to decide whether the transition fires.
83
+ *
84
+ * @remarks
85
+ * Supports both simple boolean rules (`true` / `false`) and compound
86
+ * match rules with optional AND / OR gating. When the rule includes
87
+ * a `match` array, each entry's `actual` expression is resolved via
88
+ * {@link Pipe.resolve} and compared to the `expected` value.
89
+ *
90
+ * @param transitionRule - A boolean shorthand or a {@link TransitionRule}
91
+ * containing `code`, optional `gate`, and optional `match` conditions.
92
+ * @param context - The current {@link JobState} used to resolve
93
+ * `actual` expressions inside match entries.
94
+ * @param code - The {@link StreamCode} returned by the preceding
95
+ * activity (e.g. `200`).
96
+ * @returns `true` if the transition should be taken, `false` otherwise.
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * const shouldTransition = MapperService.evaluate(
101
+ * { code: 200, match: [{ expected: true, actual: '{data.isReady}' }] },
102
+ * jobState,
103
+ * 200,
104
+ * );
105
+ * ```
51
106
  */
52
107
  static evaluate(transitionRule, context, code) {
53
108
  if (typeof transitionRule === 'boolean') {