@hotmeshio/hotmesh 0.24.1 → 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.24.1",
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) {
@@ -315,6 +315,26 @@ const KVTables = (context) => ({
315
315
  CREATE INDEX IF NOT EXISTS idx_hmsh_esc_task
316
316
  ON public.hmsh_escalations(task_id)
317
317
  WHERE task_id IS NOT NULL;
318
+ `);
319
+ // v0.25.0: escalationStats indexes. Callers filter by role (optional) and
320
+ // namespace (optional) — never namespace-first — so the pending index leads
321
+ // with role and the two windowed indexes lead with their time column: the
322
+ // scan stays window-bounded even for unfiltered (admin) calls. Trailing
323
+ // columns cover every column the stats aggregates touch, keeping each of
324
+ // the three CTE scans index-only.
325
+ await client.query(`
326
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_pending
327
+ ON public.hmsh_escalations(role, type, assigned_to, assigned_until, namespace)
328
+ WHERE status = 'pending';
329
+ `);
330
+ await client.query(`
331
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_created
332
+ ON public.hmsh_escalations(created_at, role, namespace);
333
+ `);
334
+ await client.query(`
335
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_resolved
336
+ ON public.hmsh_escalations(resolved_at, type, role, namespace)
337
+ WHERE status = 'resolved';
318
338
  `);
319
339
  await client.query('COMMIT');
320
340
  },
@@ -440,6 +460,23 @@ const KVTables = (context) => ({
440
460
  CREATE INDEX IF NOT EXISTS idx_hmsh_esc_task
441
461
  ON public.hmsh_escalations(task_id)
442
462
  WHERE task_id IS NOT NULL;
463
+ `);
464
+ // escalationStats indexes: role/time-led so aggregate scans stay
465
+ // bounded (backlog or window) and index-only. See migrate() for
466
+ // the column-order rationale.
467
+ await client.query(`
468
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_pending
469
+ ON public.hmsh_escalations(role, type, assigned_to, assigned_until, namespace)
470
+ WHERE status = 'pending';
471
+ `);
472
+ await client.query(`
473
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_created
474
+ ON public.hmsh_escalations(created_at, role, namespace);
475
+ `);
476
+ await client.query(`
477
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_resolved
478
+ ON public.hmsh_escalations(resolved_at, type, role, namespace)
479
+ WHERE status = 'resolved';
443
480
  `);
444
481
  break;
445
482
  case 'relational_connection':
@@ -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
@@ -2051,45 +2083,65 @@ class PostgresStoreService extends __1.StoreService {
2051
2083
  queryParams.push(namespace);
2052
2084
  if (roles?.length)
2053
2085
  queryParams.push(roles);
2086
+ // Three bounded sources instead of one all-history scan (v0.25.0):
2087
+ // backlog — status='pending' partial (bounded by queue depth, never history)
2088
+ // created — created_at window range (all statuses)
2089
+ // resolved — status='resolved' + resolved_at window range
2090
+ // Totals derive from the grouped CTEs (NULL role/type groups included, so
2091
+ // totals match the pre-0.25.0 all-row counts); NULL groups are excluded
2092
+ // only from the by_role/by_type rollups, as before. resolved_at is only
2093
+ // ever set alongside status='resolved' (and never survives a transition
2094
+ // back to pending), so the status predicate is a pure index enabler.
2095
+ // Roles/types with no pending rows and no window activity no longer emit
2096
+ // zero-count rollup entries.
2054
2097
  const result = await this.pgClient.query(`
2055
- WITH base AS (
2056
- SELECT * FROM public.hmsh_escalations
2057
- WHERE true ${nsClause} ${rolClause}
2058
- ),
2059
- totals AS (
2060
- SELECT
2061
- COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
2062
- COUNT(*) FILTER (WHERE status = 'pending'
2063
- AND assigned_to IS NOT NULL
2064
- AND assigned_until IS NOT NULL AND assigned_until > NOW())::int AS claimed,
2065
- COUNT(*) FILTER (WHERE created_at >= NOW() - ($1 * INTERVAL '1 hour'))::int AS created,
2066
- COUNT(*) FILTER (WHERE resolved_at >= NOW() - ($1 * INTERVAL '1 hour'))::int AS resolved
2067
- FROM base
2068
- ),
2069
- by_role AS (
2098
+ WITH pending_by_role AS (
2070
2099
  SELECT role,
2071
- COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
2072
- COUNT(*) FILTER (WHERE status = 'pending'
2073
- AND assigned_to IS NOT NULL
2100
+ COUNT(*)::int AS pending,
2101
+ COUNT(*) FILTER (WHERE assigned_to IS NOT NULL
2074
2102
  AND assigned_until IS NOT NULL AND assigned_until > NOW())::int AS claimed
2075
- FROM base WHERE role IS NOT NULL
2103
+ FROM public.hmsh_escalations
2104
+ WHERE status = 'pending' ${nsClause} ${rolClause}
2076
2105
  GROUP BY role
2077
2106
  ),
2078
- by_type AS (
2107
+ pending_by_type AS (
2079
2108
  SELECT type,
2080
- COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
2081
- COUNT(*) FILTER (WHERE status = 'pending'
2082
- AND assigned_to IS NOT NULL
2083
- AND assigned_until IS NOT NULL AND assigned_until > NOW())::int AS claimed,
2084
- COUNT(*) FILTER (WHERE resolved_at >= NOW() - ($1 * INTERVAL '1 hour'))::int AS resolved
2085
- FROM base WHERE type IS NOT NULL
2109
+ COUNT(*)::int AS pending,
2110
+ COUNT(*) FILTER (WHERE assigned_to IS NOT NULL
2111
+ AND assigned_until IS NOT NULL AND assigned_until > NOW())::int AS claimed
2112
+ FROM public.hmsh_escalations
2113
+ WHERE status = 'pending' ${nsClause} ${rolClause}
2114
+ GROUP BY type
2115
+ ),
2116
+ created_agg AS (
2117
+ SELECT COUNT(*)::int AS created
2118
+ FROM public.hmsh_escalations
2119
+ WHERE created_at >= NOW() - ($1 * INTERVAL '1 hour') ${nsClause} ${rolClause}
2120
+ ),
2121
+ resolved_by_type AS (
2122
+ SELECT type, COUNT(*)::int AS resolved
2123
+ FROM public.hmsh_escalations
2124
+ WHERE status = 'resolved'
2125
+ AND resolved_at >= NOW() - ($1 * INTERVAL '1 hour') ${nsClause} ${rolClause}
2086
2126
  GROUP BY type
2087
2127
  )
2088
2128
  SELECT
2089
- t.pending, t.claimed, t.created, t.resolved,
2090
- COALESCE((SELECT json_agg(json_build_object('role',role,'pending',pending,'claimed',claimed)) FROM by_role), '[]'::json) AS by_role,
2091
- COALESCE((SELECT json_agg(json_build_object('type',type,'pending',pending,'claimed',claimed,'resolved',resolved)) FROM by_type), '[]'::json) AS by_type
2092
- FROM totals t
2129
+ COALESCE((SELECT SUM(pending)::int FROM pending_by_role), 0) AS pending,
2130
+ COALESCE((SELECT SUM(claimed)::int FROM pending_by_role), 0) AS claimed,
2131
+ (SELECT created FROM created_agg) AS created,
2132
+ COALESCE((SELECT SUM(resolved)::int FROM resolved_by_type), 0) AS resolved,
2133
+ COALESCE((SELECT json_agg(json_build_object('role',role,'pending',pending,'claimed',claimed))
2134
+ FROM pending_by_role WHERE role IS NOT NULL), '[]'::json) AS by_role,
2135
+ COALESCE((SELECT json_agg(json_build_object('type',t.type,'pending',t.pending,'claimed',t.claimed,'resolved',t.resolved))
2136
+ FROM (
2137
+ SELECT COALESCE(p.type, r.type) AS type,
2138
+ COALESCE(p.pending, 0) AS pending,
2139
+ COALESCE(p.claimed, 0) AS claimed,
2140
+ COALESCE(r.resolved, 0) AS resolved
2141
+ FROM pending_by_type p
2142
+ FULL OUTER JOIN resolved_by_type r ON r.type = p.type
2143
+ WHERE COALESCE(p.type, r.type) IS NOT NULL
2144
+ ) t), '[]'::json) AS by_type
2093
2145
  `, queryParams);
2094
2146
  const row = result.rows[0];
2095
2147
  return {
@@ -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.24.1",
3
+ "version": "0.25.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",