@hotmeshio/hotmesh 0.25.0 → 0.25.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -272,6 +272,13 @@ declare class Hook extends Activity {
272
272
  doesHook(): boolean;
273
273
  doHook(telemetry: TelemetryService): Promise<void>;
274
274
  private addEscalationToTransaction;
275
+ /**
276
+ * Derive signal_key from the hook rule's expected condition — the same
277
+ * value registerWebHook stores as the signal lookup key. Used by the Leg1
278
+ * escalation INSERT and by the timeout path's expiry UPDATE, so both sides
279
+ * address the identical row.
280
+ */
281
+ private deriveEscalationSignalKey;
275
282
  /**
276
283
  * Re-publishes a pending signal as a WEBHOOK stream message so the
277
284
  * normal leg2 dispatch path processes it. Called when leg1's
@@ -308,5 +315,14 @@ declare class Hook extends Activity {
308
315
  } | void>;
309
316
  processWebHookEvent(status?: StreamStatus, code?: StreamCode): Promise<JobStatus | void>;
310
317
  processTimeHookEvent(jobId: string): Promise<JobStatus | void>;
318
+ /**
319
+ * The timeout won the race: transition the wait's escalation row
320
+ * `pending → expired` so the worklist stops offering work whose workflow
321
+ * has already resumed with `false`, and a late resolve fails as
322
+ * already-expired instead of delivering a payload into the void. The
323
+ * UPDATE is guarded by status='pending' — when the signal won (row
324
+ * already resolved) or the wait carried no escalation, it is a no-op.
325
+ */
326
+ private expireEscalationOnTimeout;
311
327
  }
312
328
  export { Hook };
@@ -437,12 +437,7 @@ class Hook extends activity_1.Activity {
437
437
  if (params.role == null && params.type == null &&
438
438
  params.priority == null && params.metadata == null)
439
439
  return null;
440
- // Derive signal_key from the hook rule's expected condition — the same
441
- // value registerWebHook stores as the signal lookup key.
442
- const hookRule = await this.getHookRule(this.config.hook.topic);
443
- const signalKey = hookRule?.conditions?.match?.[0]?.expected
444
- ? pipe_1.Pipe.resolve(hookRule.conditions.match[0].expected, this.context)
445
- : jid;
440
+ const signalKey = await this.deriveEscalationSignalKey();
446
441
  store.addEscalationToTransaction({
447
442
  namespace,
448
443
  appId,
@@ -453,6 +448,18 @@ class Hook extends activity_1.Activity {
453
448
  }, transaction);
454
449
  return signalKey;
455
450
  }
451
+ /**
452
+ * Derive signal_key from the hook rule's expected condition — the same
453
+ * value registerWebHook stores as the signal lookup key. Used by the Leg1
454
+ * escalation INSERT and by the timeout path's expiry UPDATE, so both sides
455
+ * address the identical row.
456
+ */
457
+ async deriveEscalationSignalKey() {
458
+ const hookRule = await this.getHookRule(this.config.hook.topic);
459
+ return hookRule?.conditions?.match?.[0]?.expected
460
+ ? pipe_1.Pipe.resolve(hookRule.conditions.match[0].expected, this.context)
461
+ : this.context.metadata.jid;
462
+ }
456
463
  /**
457
464
  * Re-publishes a pending signal as a WEBHOOK stream message so the
458
465
  * normal leg2 dispatch path processes it. Called when leg1's
@@ -629,8 +636,59 @@ class Hook extends activity_1.Activity {
629
636
  error: e.message,
630
637
  });
631
638
  }
639
+ await this.expireEscalationOnTimeout();
632
640
  }
633
641
  await this.processEvent(stream_1.StreamStatus.SUCCESS, 200, 'hook');
634
642
  }
643
+ /**
644
+ * The timeout won the race: transition the wait's escalation row
645
+ * `pending → expired` so the worklist stops offering work whose workflow
646
+ * has already resumed with `false`, and a late resolve fails as
647
+ * already-expired instead of delivering a payload into the void. The
648
+ * UPDATE is guarded by status='pending' — when the signal won (row
649
+ * already resolved) or the wait carried no escalation, it is a no-op.
650
+ */
651
+ async expireEscalationOnTimeout() {
652
+ if (!this.config.escalation)
653
+ return;
654
+ const store = this.store;
655
+ if (typeof store.expireEscalationBySignalKey !== 'function')
656
+ return;
657
+ try {
658
+ // The TIMEHOOK dispatch context carries metadata only — hydrate job
659
+ // state so the signal-key pipe expression resolves to the same value
660
+ // Leg1 stored on the row.
661
+ this.setLeg(2);
662
+ await this.getState();
663
+ const appId = this.engine.appId;
664
+ const namespace = this.engine.namespace ?? appId;
665
+ const signalKey = await this.deriveEscalationSignalKey();
666
+ const row = await store.expireEscalationBySignalKey(signalKey, namespace, appId);
667
+ if (row?.id && store.eventsPublish) {
668
+ const ts = new Date().toISOString();
669
+ const updatedAt = row.updated_at ? new Date(row.updated_at).toISOString() : ts;
670
+ void Promise.resolve(store.eventsPublish({
671
+ event_id: `${row.id}:expired:${updatedAt}`,
672
+ type: `system.escalation.${row.id}.expired`,
673
+ ts,
674
+ namespace: row.namespace ?? namespace,
675
+ app_id: row.app_id ?? appId,
676
+ workflow_id: row.workflow_id ?? undefined,
677
+ topic: row.topic ?? undefined,
678
+ origin_id: row.origin_id ?? undefined,
679
+ parent_id: row.parent_id ?? undefined,
680
+ trace_id: row.trace_id ?? undefined,
681
+ span_id: row.span_id ?? undefined,
682
+ data: row,
683
+ })).catch(() => { });
684
+ }
685
+ }
686
+ catch (e) {
687
+ this.logger.debug('hook-timeout-escalation-expire', {
688
+ topic: this.config.hook?.topic,
689
+ error: e.message,
690
+ });
691
+ }
692
+ }
635
693
  }
636
694
  exports.Hook = Hook;
@@ -61,8 +61,10 @@ import { ConditionQueueConfig } from '../../../types/hmsh_escalations';
61
61
  * description: 'Approve or reject the regional order',
62
62
  * metadata: { orderId, region },
63
63
  * envelope: { instructions: 'Review the attached order' },
64
+ * timeout: '72h', // SLA: resume with false + expire the row if unresolved
64
65
  * },
65
66
  * );
67
+ * if (decision === false) return 'auto-rejected-sla'; // row is now status='expired'
66
68
  *
67
69
  * // Elsewhere: list, claim, then resolve (resumes the workflow)
68
70
  * const [item] = await client.escalations.list({ role: 'manager', status: 'pending' });
@@ -95,9 +97,13 @@ import { ConditionQueueConfig } from '../../../types/hmsh_escalations';
95
97
  * @param signalId - A unique signal identifier shared by the sender and receiver.
96
98
  * @param timeoutOrConfig - Optional timeout string (e.g. `'30s'`, `'24h'`) OR a
97
99
  * {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
98
- * atomically at suspension time. Cannot specify both; use the config object's
99
- * `expiresAt` field for deadline enforcement when an escalation is involved.
100
- * @returns The signal payload, `false` if a timeout string was given and it expired,
101
- * or `null` if the escalation was cancelled via `client.escalations.cancel()`.
100
+ * atomically at suspension time. For an escalation-bearing wait with an SLA,
101
+ * set the config's `timeout` field the wait arms the same resume timer as
102
+ * the string form, and when the timer wins the escalation row transitions
103
+ * `pending → expired` so a late resolve fails as already-expired. (`expiresAt`
104
+ * is display metadata on the row only; it arms nothing.)
105
+ * @returns The signal payload, `false` when a timeout (string form or
106
+ * `config.timeout`) expired first, or `null` if the escalation was cancelled
107
+ * via `client.escalations.cancel()`.
102
108
  */
103
109
  export declare function condition<T>(signalId: string, timeoutOrConfig?: string | ConditionQueueConfig): Promise<T | false | null>;
@@ -66,8 +66,10 @@ const didRun_1 = require("./didRun");
66
66
  * description: 'Approve or reject the regional order',
67
67
  * metadata: { orderId, region },
68
68
  * envelope: { instructions: 'Review the attached order' },
69
+ * timeout: '72h', // SLA: resume with false + expire the row if unresolved
69
70
  * },
70
71
  * );
72
+ * if (decision === false) return 'auto-rejected-sla'; // row is now status='expired'
71
73
  *
72
74
  * // Elsewhere: list, claim, then resolve (resumes the workflow)
73
75
  * const [item] = await client.escalations.list({ role: 'manager', status: 'pending' });
@@ -100,14 +102,21 @@ const didRun_1 = require("./didRun");
100
102
  * @param signalId - A unique signal identifier shared by the sender and receiver.
101
103
  * @param timeoutOrConfig - Optional timeout string (e.g. `'30s'`, `'24h'`) OR a
102
104
  * {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
103
- * atomically at suspension time. Cannot specify both; use the config object's
104
- * `expiresAt` field for deadline enforcement when an escalation is involved.
105
- * @returns The signal payload, `false` if a timeout string was given and it expired,
106
- * or `null` if the escalation was cancelled via `client.escalations.cancel()`.
105
+ * atomically at suspension time. For an escalation-bearing wait with an SLA,
106
+ * set the config's `timeout` field the wait arms the same resume timer as
107
+ * the string form, and when the timer wins the escalation row transitions
108
+ * `pending → expired` so a late resolve fails as already-expired. (`expiresAt`
109
+ * is display metadata on the row only; it arms nothing.)
110
+ * @returns The signal payload, `false` when a timeout (string form or
111
+ * `config.timeout`) expired first, or `null` if the escalation was cancelled
112
+ * via `client.escalations.cancel()`.
107
113
  */
108
114
  async function condition(signalId, timeoutOrConfig) {
109
- const timeout = typeof timeoutOrConfig === 'string' ? timeoutOrConfig : undefined;
115
+ // A string arg is a bare timeout; a config object may carry its own
116
+ // `timeout` field — the engine's waiter block accepts duration and
117
+ // queueConfig together (one wait: escalation row + resume timer).
110
118
  const queueConfig = timeoutOrConfig && typeof timeoutOrConfig === 'object' ? timeoutOrConfig : undefined;
119
+ const timeout = typeof timeoutOrConfig === 'string' ? timeoutOrConfig : queueConfig?.timeout;
111
120
  const [didRunAlready, execIndex, result] = await (0, didRun_1.didRun)('wait');
112
121
  (0, cancellationScope_1.checkCancellation)();
113
122
  if (didRunAlready) {
@@ -256,6 +256,14 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
256
256
  createEscalationForMigration(params: import('../../../../types/hmsh_escalations').MigrateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
257
257
  getEscalation(id: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
258
258
  getEscalationBySignalKey(signalKey: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
259
+ /**
260
+ * Transition a wait's escalation row to `expired` when its resume timer
261
+ * fires first (`condition(signalId, { ..., timeout })`). Guarded by
262
+ * `status = 'pending'`: a signal that won the race already resolved the
263
+ * row and is never touched. Returns the expired row, or null when no
264
+ * pending row carried the key (signal won, or the wait had no escalation).
265
+ */
266
+ expireEscalationBySignalKey(signalKey: string, namespace?: string, appId?: string): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
259
267
  private _escalationFilterConditions;
260
268
  listEscalations(params?: import('../../../../types/hmsh_escalations').ListEscalationsParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry[]>;
261
269
  countEscalations(params?: import('../../../../types/hmsh_escalations').ListEscalationsParams): Promise<number>;
@@ -1540,6 +1540,30 @@ class PostgresStoreService extends __1.StoreService {
1540
1540
  const result = await this.pgClient.query(`SELECT * FROM public.hmsh_escalations WHERE signal_key = $1${namespace ? ' AND namespace = $2' : ''}`, namespace ? [signalKey, namespace] : [signalKey]);
1541
1541
  return result.rows[0] ?? null;
1542
1542
  }
1543
+ /**
1544
+ * Transition a wait's escalation row to `expired` when its resume timer
1545
+ * fires first (`condition(signalId, { ..., timeout })`). Guarded by
1546
+ * `status = 'pending'`: a signal that won the race already resolved the
1547
+ * row and is never touched. Returns the expired row, or null when no
1548
+ * pending row carried the key (signal won, or the wait had no escalation).
1549
+ */
1550
+ async expireEscalationBySignalKey(signalKey, namespace, appId) {
1551
+ const values = [signalKey];
1552
+ let clause = '';
1553
+ if (namespace) {
1554
+ values.push(namespace);
1555
+ clause += ` AND namespace = $${values.length}`;
1556
+ }
1557
+ if (appId) {
1558
+ values.push(appId);
1559
+ clause += ` AND app_id = $${values.length}`;
1560
+ }
1561
+ const result = await this.pgClient.query(`UPDATE public.hmsh_escalations
1562
+ SET status = 'expired', updated_at = NOW()
1563
+ WHERE signal_key = $1${clause} AND status = 'pending'
1564
+ RETURNING *`, values);
1565
+ return result.rows[0] ?? null;
1566
+ }
1543
1567
  _escalationFilterConditions(params, startIdx = 1) {
1544
1568
  const { namespace, role, roles, type, subtype, entity, status, assignedTo, workflowId, originId, available, priority, metadata, ids, taskId } = params;
1545
1569
  const conditions = [];
@@ -1791,6 +1815,14 @@ class PostgresStoreService extends __1.StoreService {
1791
1815
  await this.pgClient.query('ROLLBACK');
1792
1816
  return { ok: false, reason: 'already-cancelled' };
1793
1817
  }
1818
+ if (status === 'expired') {
1819
+ // The wait's SLA timer fired first and the workflow resumed with
1820
+ // false — the resolver payload has nowhere to go. Name it, so the
1821
+ // operator learns the deadline passed rather than believing the
1822
+ // resolution landed.
1823
+ await this.pgClient.query('ROLLBACK');
1824
+ return { ok: false, reason: 'already-expired' };
1825
+ }
1794
1826
  const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1795
1827
  SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1796
1828
  metadata = CASE WHEN $3::jsonb IS NOT NULL
@@ -17,6 +17,14 @@ export interface ConditionQueueConfig {
17
17
  /** Unindexed display/form context for resolver UIs */
18
18
  envelope?: Record<string, unknown>;
19
19
  expiresAt?: Date;
20
+ /**
21
+ * SLA timer for the wait itself (e.g. `'30m'`, `'24h'`). Arms the same
22
+ * resume timer as `condition(signalId, '30m')`: when it fires first, the
23
+ * workflow resumes with `false` and the escalation row transitions
24
+ * `pending → expired` (a later resolve fails as already-expired). A signal
25
+ * that arrives first resolves normally and the timer is inert.
26
+ */
27
+ timeout?: string;
20
28
  }
21
29
  export interface EscalationEntry {
22
30
  id: string;
@@ -95,7 +103,7 @@ export type ResolveEscalationResult = {
95
103
  entry: EscalationEntry;
96
104
  } | {
97
105
  ok: false;
98
- reason: 'not-found' | 'already-resolved' | 'already-cancelled';
106
+ reason: 'not-found' | 'already-resolved' | 'already-cancelled' | 'already-expired';
99
107
  };
100
108
  export type ReleaseEscalationResult = {
101
109
  ok: true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",