@hotmeshio/hotmesh 0.22.1 → 0.22.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.
@@ -1511,7 +1511,7 @@ class PostgresStoreService extends __1.StoreService {
1511
1511
  return result.rows[0] ?? null;
1512
1512
  }
1513
1513
  async listEscalations(params = {}) {
1514
- const { namespace, role, type, subtype, entity, status, assignedTo, workflowId, originId, limit = 50, offset = 0 } = params;
1514
+ const { namespace, role, roles, type, subtype, entity, status, assignedTo, workflowId, originId, available, sortBy, sortOrder, limit = 50, offset = 0 } = params;
1515
1515
  const conditions = [];
1516
1516
  const values = [];
1517
1517
  let idx = 1;
@@ -1519,7 +1519,12 @@ class PostgresStoreService extends __1.StoreService {
1519
1519
  conditions.push(`namespace = $${idx++}`);
1520
1520
  values.push(namespace);
1521
1521
  }
1522
- if (role) {
1522
+ // roles[] takes precedence over role when both provided
1523
+ if (roles?.length) {
1524
+ conditions.push(`role = ANY($${idx++}::text[])`);
1525
+ values.push(roles);
1526
+ }
1527
+ else if (role) {
1523
1528
  conditions.push(`role = $${idx++}`);
1524
1529
  values.push(role);
1525
1530
  }
@@ -1551,17 +1556,86 @@ class PostgresStoreService extends __1.StoreService {
1551
1556
  conditions.push(`origin_id = $${idx++}`);
1552
1557
  values.push(originId);
1553
1558
  }
1559
+ if (available === true) {
1560
+ conditions.push(`(assigned_to IS NULL OR (assigned_until IS NOT NULL AND assigned_until <= NOW()))`);
1561
+ }
1562
+ if (available === false) {
1563
+ conditions.push(`(assigned_to IS NOT NULL AND (assigned_until IS NULL OR assigned_until > NOW()))`);
1564
+ }
1554
1565
  const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
1566
+ const col = sortBy === 'updated_at' ? 'updated_at' : sortBy === 'created_at' ? 'created_at' : 'priority';
1567
+ const dir = sortOrder === 'desc' ? 'DESC' : 'ASC';
1568
+ // Secondary sort is always created_at DESC for deterministic page ordering
1569
+ const orderBy = col === 'priority'
1570
+ ? `ORDER BY priority ${dir}, created_at DESC`
1571
+ : `ORDER BY ${col} ${dir}`;
1555
1572
  values.push(limit, offset);
1556
- const result = await this.pgClient.query(`SELECT * FROM public.hmsh_escalations ${where} ORDER BY priority ASC, created_at ASC LIMIT $${idx++} OFFSET $${idx}`, values);
1573
+ const result = await this.pgClient.query(`SELECT *,
1574
+ (assigned_to IS NULL OR (assigned_until IS NOT NULL AND assigned_until <= NOW())) AS available
1575
+ FROM public.hmsh_escalations ${where} ${orderBy} LIMIT $${idx++} OFFSET $${idx}`, values);
1557
1576
  return result.rows;
1558
1577
  }
1578
+ async countEscalations(params = {}) {
1579
+ const { namespace, role, roles, type, subtype, entity, status, assignedTo, workflowId, originId, available } = params;
1580
+ const conditions = [];
1581
+ const values = [];
1582
+ let idx = 1;
1583
+ if (namespace) {
1584
+ conditions.push(`namespace = $${idx++}`);
1585
+ values.push(namespace);
1586
+ }
1587
+ if (roles?.length) {
1588
+ conditions.push(`role = ANY($${idx++}::text[])`);
1589
+ values.push(roles);
1590
+ }
1591
+ else if (role) {
1592
+ conditions.push(`role = $${idx++}`);
1593
+ values.push(role);
1594
+ }
1595
+ if (type) {
1596
+ conditions.push(`type = $${idx++}`);
1597
+ values.push(type);
1598
+ }
1599
+ if (subtype) {
1600
+ conditions.push(`subtype = $${idx++}`);
1601
+ values.push(subtype);
1602
+ }
1603
+ if (entity) {
1604
+ conditions.push(`entity = $${idx++}`);
1605
+ values.push(entity);
1606
+ }
1607
+ if (status) {
1608
+ conditions.push(`status = $${idx++}`);
1609
+ values.push(status);
1610
+ }
1611
+ if (assignedTo) {
1612
+ conditions.push(`assigned_to = $${idx++}`);
1613
+ values.push(assignedTo);
1614
+ }
1615
+ if (workflowId) {
1616
+ conditions.push(`workflow_id = $${idx++}`);
1617
+ values.push(workflowId);
1618
+ }
1619
+ if (originId) {
1620
+ conditions.push(`origin_id = $${idx++}`);
1621
+ values.push(originId);
1622
+ }
1623
+ if (available === true) {
1624
+ conditions.push(`(assigned_to IS NULL OR (assigned_until IS NOT NULL AND assigned_until <= NOW()))`);
1625
+ }
1626
+ if (available === false) {
1627
+ conditions.push(`(assigned_to IS NOT NULL AND (assigned_until IS NULL OR assigned_until > NOW()))`);
1628
+ }
1629
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
1630
+ const result = await this.pgClient.query(`SELECT COUNT(*)::int AS total FROM public.hmsh_escalations ${where}`, values);
1631
+ return result.rows[0]?.total ?? 0;
1632
+ }
1559
1633
  async claimEscalation(params) {
1560
1634
  const { id, namespace, assignee = '', durationMinutes = 1 } = params;
1561
- // all_rows: reads the row without locking, so we can distinguish 'not-found' vs 'conflict'
1562
- // target: attempts the lock; SKIP LOCKED means a locked row is skipped without error
1563
- // claimed: performs the UPDATE only if target succeeded
1564
- // The final SELECT always returns exactly 1 row via the dummy left-join — no second round-trip.
1635
+ // Claims are implicit: status stays 'pending'; availability = assigned_to IS NULL OR assigned_until <= NOW().
1636
+ // all_rows: unlocked read to distinguish 'not-found' vs 'conflict'.
1637
+ // target: locks the row only when it is available (no active claim or same assignee re-claiming).
1638
+ // claimed: writes both assigned_until and claim_expires_at (same value) for service-layer compat.
1565
1639
  const result = await this.pgClient.query(`
1566
1640
  WITH all_rows AS MATERIALIZED (
1567
1641
  SELECT id FROM public.hmsh_escalations
@@ -1572,16 +1646,16 @@ class PostgresStoreService extends __1.StoreService {
1572
1646
  WHERE id = $1
1573
1647
  ${namespace ? 'AND namespace = $4' : ''}
1574
1648
  AND status = 'pending'
1575
- AND (assigned_to IS NULL OR claim_expires_at <= NOW() OR assigned_to = $2)
1649
+ AND (assigned_to IS NULL OR assigned_until IS NULL OR assigned_until <= NOW() OR assigned_to = $2)
1576
1650
  LIMIT 1
1577
1651
  FOR UPDATE SKIP LOCKED
1578
1652
  ),
1579
1653
  claimed AS (
1580
1654
  UPDATE public.hmsh_escalations
1581
- SET status = 'claimed',
1582
- assigned_to = $2,
1655
+ SET assigned_to = $2,
1583
1656
  claimed_at = NOW(),
1584
1657
  claim_expires_at = NOW() + ($3 * INTERVAL '1 minute'),
1658
+ assigned_until = NOW() + ($3 * INTERVAL '1 minute'),
1585
1659
  updated_at = NOW()
1586
1660
  FROM target WHERE public.hmsh_escalations.id = target.id
1587
1661
  RETURNING public.hmsh_escalations.*
@@ -1600,10 +1674,8 @@ class PostgresStoreService extends __1.StoreService {
1600
1674
  async claimEscalationByMetadata(params) {
1601
1675
  const { key, value, namespace, assignee = '', durationMinutes = 1, roles } = params;
1602
1676
  const filter = JSON.stringify({ [key]: value });
1603
- // all_candidates: counts ALL matching rows (any status) to distinguish 'not-found' vs 'conflict'
1604
- // target: attempts the lock on the highest-priority pending row; SKIP LOCKED skips already-claimed ones
1605
- // claimed: performs the UPDATE atomically from target
1606
- // The dummy left-join ensures exactly 1 row is always returned so candidatesExist is always visible.
1677
+ // target captures prior_assigned_to to detect re-claim (isExtension).
1678
+ // Implicit claim model: status stays 'pending'; availability = assigned_until IS NULL OR assigned_until <= NOW().
1607
1679
  const result = await this.pgClient.query(`
1608
1680
  WITH all_candidates AS MATERIALIZED (
1609
1681
  SELECT id FROM public.hmsh_escalations
@@ -1612,25 +1684,25 @@ class PostgresStoreService extends __1.StoreService {
1612
1684
  AND ($4::text[] IS NULL OR role = ANY($4::text[]))
1613
1685
  ),
1614
1686
  target AS MATERIALIZED (
1615
- SELECT id FROM public.hmsh_escalations
1687
+ SELECT id, assigned_to AS prior_assigned_to FROM public.hmsh_escalations
1616
1688
  WHERE ${namespace ? 'namespace = $5 AND' : ''}
1617
1689
  metadata @> $1::jsonb
1618
1690
  AND ($4::text[] IS NULL OR role = ANY($4::text[]))
1619
1691
  AND status = 'pending'
1620
- AND (assigned_to IS NULL OR claim_expires_at <= NOW() OR assigned_to = $2)
1692
+ AND (assigned_to IS NULL OR assigned_until IS NULL OR assigned_until <= NOW() OR assigned_to = $2)
1621
1693
  ORDER BY priority ASC, created_at ASC
1622
1694
  LIMIT 1
1623
1695
  FOR UPDATE SKIP LOCKED
1624
1696
  ),
1625
1697
  claimed AS (
1626
1698
  UPDATE public.hmsh_escalations
1627
- SET status = 'claimed',
1628
- assigned_to = $2,
1699
+ SET assigned_to = $2,
1629
1700
  claimed_at = NOW(),
1630
1701
  claim_expires_at = NOW() + ($3 * INTERVAL '1 minute'),
1702
+ assigned_until = NOW() + ($3 * INTERVAL '1 minute'),
1631
1703
  updated_at = NOW()
1632
1704
  FROM target WHERE public.hmsh_escalations.id = target.id
1633
- RETURNING public.hmsh_escalations.*
1705
+ RETURNING public.hmsh_escalations.*, target.prior_assigned_to
1634
1706
  )
1635
1707
  SELECT c.*, (SELECT COUNT(*)::int FROM all_candidates) AS candidates_exist
1636
1708
  FROM (VALUES(1)) AS dummy(v)
@@ -1640,8 +1712,10 @@ class PostgresStoreService extends __1.StoreService {
1640
1712
  : [filter, assignee, durationMinutes, roles ?? null]);
1641
1713
  const row = result.rows[0];
1642
1714
  const candidatesExist = row?.candidates_exist ?? 0;
1643
- if (row?.id)
1644
- return { ok: true, entry: row, candidatesExist };
1715
+ if (row?.id) {
1716
+ const isExtension = row.prior_assigned_to === assignee;
1717
+ return { ok: true, entry: row, candidatesExist, isExtension };
1718
+ }
1645
1719
  return {
1646
1720
  ok: false,
1647
1721
  reason: candidatesExist > 0 ? 'conflict' : 'not-found',
@@ -1657,7 +1731,7 @@ class PostgresStoreService extends __1.StoreService {
1657
1731
  WITH target AS MATERIALIZED (
1658
1732
  SELECT id, assigned_to FROM public.hmsh_escalations
1659
1733
  WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
1660
- AND status = 'claimed'
1734
+ AND status = 'pending' AND assigned_to IS NOT NULL
1661
1735
  FOR UPDATE
1662
1736
  ),
1663
1737
  released AS (
@@ -1682,6 +1756,7 @@ class PostgresStoreService extends __1.StoreService {
1682
1756
  }
1683
1757
  async resolveEscalation(params) {
1684
1758
  const { id, namespace, resolverPayload } = params;
1759
+ const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1685
1760
  const result = await this.pgClient.query(`
1686
1761
  WITH target AS MATERIALIZED (
1687
1762
  SELECT id, signal_key, topic, status FROM public.hmsh_escalations
@@ -1696,7 +1771,7 @@ class PostgresStoreService extends __1.StoreService {
1696
1771
  updated_at = NOW()
1697
1772
  FROM target
1698
1773
  WHERE public.hmsh_escalations.id = target.id
1699
- AND target.status IN ('pending', 'claimed')
1774
+ AND target.status = 'pending'
1700
1775
  RETURNING public.hmsh_escalations.id
1701
1776
  )
1702
1777
  SELECT t.signal_key, t.topic, t.status AS prior_status,
@@ -1709,8 +1784,8 @@ class PostgresStoreService extends __1.StoreService {
1709
1784
  FROM (SELECT * FROM target) t
1710
1785
  FULL OUTER JOIN (SELECT id FROM resolved) r ON r.id = t.id
1711
1786
  `, namespace
1712
- ? [id, resolverPayload ? JSON.stringify(resolverPayload) : null, namespace]
1713
- : [id, resolverPayload ? JSON.stringify(resolverPayload) : null]);
1787
+ ? [id, payloadJson, namespace]
1788
+ : [id, payloadJson]);
1714
1789
  if (!result.rows[0] || result.rows[0].outcome === 'not-found')
1715
1790
  return { ok: false, reason: 'not-found' };
1716
1791
  if (result.rows[0].outcome === 'already-cancelled')
@@ -1722,6 +1797,7 @@ class PostgresStoreService extends __1.StoreService {
1722
1797
  async resolveEscalationByMetadata(params) {
1723
1798
  const { key, value, namespace, resolverPayload, roles } = params;
1724
1799
  const filter = JSON.stringify({ [key]: value });
1800
+ const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1725
1801
  const result = await this.pgClient.query(`
1726
1802
  WITH target AS MATERIALIZED (
1727
1803
  SELECT id, signal_key, topic, status FROM public.hmsh_escalations
@@ -1739,7 +1815,7 @@ class PostgresStoreService extends __1.StoreService {
1739
1815
  updated_at = NOW()
1740
1816
  FROM target
1741
1817
  WHERE public.hmsh_escalations.id = target.id
1742
- AND target.status IN ('pending', 'claimed')
1818
+ AND target.status = 'pending'
1743
1819
  RETURNING public.hmsh_escalations.id
1744
1820
  )
1745
1821
  SELECT t.signal_key, t.topic, t.status AS prior_status,
@@ -1752,8 +1828,8 @@ class PostgresStoreService extends __1.StoreService {
1752
1828
  FROM (SELECT * FROM target) t
1753
1829
  FULL OUTER JOIN (SELECT id FROM resolved) r ON r.id = t.id
1754
1830
  `, namespace
1755
- ? [filter, resolverPayload ? JSON.stringify(resolverPayload) : null, roles ?? null, namespace]
1756
- : [filter, resolverPayload ? JSON.stringify(resolverPayload) : null, roles ?? null]);
1831
+ ? [filter, payloadJson, roles ?? null, namespace]
1832
+ : [filter, payloadJson, roles ?? null]);
1757
1833
  if (!result.rows[0] || result.rows[0].outcome === 'not-found')
1758
1834
  return { ok: false, reason: 'not-found' };
1759
1835
  if (result.rows[0].outcome === 'already-cancelled')
@@ -1809,7 +1885,7 @@ class PostgresStoreService extends __1.StoreService {
1809
1885
  SET status = 'cancelled', updated_at = NOW()
1810
1886
  FROM target
1811
1887
  WHERE public.hmsh_escalations.id = target.id
1812
- AND target.status IN ('pending', 'claimed')
1888
+ AND target.status = 'pending'
1813
1889
  RETURNING public.hmsh_escalations.id
1814
1890
  )
1815
1891
  SELECT t.id, t.status AS prior_status,
@@ -1839,7 +1915,7 @@ class PostgresStoreService extends __1.StoreService {
1839
1915
  claim_expires_at = NULL,
1840
1916
  updated_at = NOW()
1841
1917
  WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
1842
- AND status IN ('pending', 'claimed')
1918
+ AND status = 'pending'
1843
1919
  RETURNING *
1844
1920
  `, namespace ? [id, targetRole, namespace] : [id, targetRole]);
1845
1921
  return result.rows[0] ?? null;
@@ -1915,16 +1991,10 @@ class PostgresStoreService extends __1.StoreService {
1915
1991
  `, namespace ? [id, JSON.stringify(stamped), namespace] : [id, JSON.stringify(stamped)]);
1916
1992
  return result.rows[0] ?? null;
1917
1993
  }
1918
- async releaseExpiredEscalations(namespace) {
1919
- const result = await this.pgClient.query(`
1920
- UPDATE public.hmsh_escalations
1921
- SET status = 'pending', assigned_to = NULL, assigned_until = NULL,
1922
- claimed_at = NULL, claim_expires_at = NULL, updated_at = NOW()
1923
- WHERE status = 'claimed'
1924
- AND claim_expires_at <= NOW()
1925
- ${namespace ? 'AND namespace = $1' : ''}
1926
- `, namespace ? [namespace] : []);
1927
- return result.rowCount ?? 0;
1994
+ async releaseExpiredEscalations(_namespace) {
1995
+ // No-op in the implicit claim model: availability is computed at query time from
1996
+ // assigned_until, so no background sweep is needed to release expired claims.
1997
+ return 0;
1928
1998
  }
1929
1999
  }
1930
2000
  exports.PostgresStoreService = PostgresStoreService;
@@ -34,7 +34,8 @@ export interface EscalationEntry {
34
34
  entity: string | null;
35
35
  description: string | null;
36
36
  role: string | null;
37
- status: 'pending' | 'claimed' | 'resolved' | 'cancelled' | 'expired';
37
+ /** Lifecycle status. Claims are implicit: status='pending' + assigned_to IS NOT NULL + assigned_until > NOW(). */
38
+ status: 'pending' | 'resolved' | 'cancelled' | 'expired';
38
39
  priority: number;
39
40
  assigned_to: string | null;
40
41
  assigned_until: Date | null;
@@ -55,6 +56,8 @@ export interface EscalationEntry {
55
56
  expires_at: Date | null;
56
57
  created_at: Date;
57
58
  updated_at: Date;
59
+ /** Computed by list(): true when the row is claimable (no active assignee or expired claim). */
60
+ available?: boolean;
58
61
  }
59
62
  /**
60
63
  * Result of `claim()` — identifies whether failure was due to the row not
@@ -70,15 +73,15 @@ export type ClaimEscalationResult = {
70
73
  reason: 'not-found' | 'conflict';
71
74
  };
72
75
  /**
73
- * Result of `claimByMetadata()`. Includes `candidatesExist` the total
74
- * count of rows matching the metadata filter regardless of claimability — so
75
- * callers can distinguish "nothing matching at all" from "found candidates but
76
- * all are locked or in-progress".
76
+ * Result of `claimByMetadata()`. Includes `candidatesExist` and `isExtension`:
77
+ * - `candidatesExist` — total count of rows matching the filter regardless of claimability
78
+ * - `isExtension` true when the same assignee re-claims a row they already hold (extends the expiry)
77
79
  */
78
80
  export type ClaimByMetadataResult = {
79
81
  ok: true;
80
82
  entry: EscalationEntry;
81
83
  candidatesExist: number;
84
+ isExtension: boolean;
82
85
  } | {
83
86
  ok: false;
84
87
  reason: 'not-found' | 'conflict';
@@ -88,7 +91,7 @@ export type ResolveEscalationResult = {
88
91
  ok: true;
89
92
  } | {
90
93
  ok: false;
91
- reason: 'not-found' | 'already-resolved' | 'already-cancelled' | 'signal-failed';
94
+ reason: 'not-found' | 'already-resolved' | 'already-cancelled';
92
95
  };
93
96
  export type ReleaseEscalationResult = {
94
97
  ok: true;
@@ -105,6 +108,8 @@ export type CancelEscalationResult = {
105
108
  export interface ListEscalationsParams {
106
109
  namespace?: string;
107
110
  role?: string;
111
+ /** Filter by one or more roles (OR semantics; takes precedence over `role` when both set). */
112
+ roles?: string[];
108
113
  type?: string;
109
114
  subtype?: string;
110
115
  entity?: string;
@@ -112,6 +117,10 @@ export interface ListEscalationsParams {
112
117
  assignedTo?: string;
113
118
  workflowId?: string;
114
119
  originId?: string;
120
+ /** When true, returns only rows without an active claim. When false, returns only actively claimed rows. */
121
+ available?: boolean;
122
+ sortBy?: 'created_at' | 'priority' | 'updated_at';
123
+ sortOrder?: 'asc' | 'desc';
115
124
  limit?: number;
116
125
  offset?: number;
117
126
  }
package/index.ts CHANGED
@@ -18,6 +18,7 @@ import * as KeyStore from './modules/key';
18
18
  import { ConnectorService as Connector } from './services/connector/factory';
19
19
  import { PostgresConnection as ConnectorPostgres } from './services/connector/providers/postgres';
20
20
  import { NatsConnection as ConnectorNATS } from './services/connector/providers/nats';
21
+ import { Escalations } from './services/escalations';
21
22
 
22
23
  export {
23
24
  //Provider Connectors
@@ -30,6 +31,7 @@ export {
30
31
  HotMeshConfig,
31
32
  Virtual,
32
33
  Durable,
34
+ Escalations,
33
35
  DBA,
34
36
 
35
37
  //Durable Submodules
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.22.1",
3
+ "version": "0.22.2",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",