@hotmeshio/hotmesh 0.22.2 → 0.22.3

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.
@@ -49,12 +49,12 @@ class PostgresStoreService extends __1.StoreService {
49
49
  (namespace, app_id, signal_key, topic, workflow_id, task_queue, workflow_type,
50
50
  type, subtype, entity, description, role, priority,
51
51
  origin_id, parent_id, initiated_by, created_by, trace_id, span_id,
52
- escalation_payload, metadata, envelope, expires_at)
52
+ task_id, escalation_payload, metadata, envelope, expires_at)
53
53
  VALUES
54
54
  ($1, $2, $3, $4, $5, $6, $7,
55
55
  $8, $9, $10, $11, $12, $13,
56
56
  $14, $15, $16, $17, $18, $19,
57
- $20, $21, $22, $23)
57
+ $20, $21, $22, $23, $24)
58
58
  ON CONFLICT (namespace, app_id, signal_key) WHERE signal_key IS NOT NULL DO NOTHING`;
59
59
  //Instead of directly referencing the 'pg' package and methods like 'query',
60
60
  // the PostgresStore wraps the 'pg' client in a class that implements
@@ -1400,7 +1400,7 @@ class PostgresStoreService extends __1.StoreService {
1400
1400
  }
1401
1401
  }
1402
1402
  _escalationInsertParams(params) {
1403
- const { namespace, appId, signalKey, topic, workflowId, taskQueue, workflowType, type, subtype, entity, description, role, priority, originId, parentId, initiatedBy, createdBy, traceId, spanId, escalationPayload, metadata, envelope, expiresAt, } = params;
1403
+ const { namespace, appId, signalKey, topic, workflowId, taskQueue, workflowType, type, subtype, entity, description, role, priority, originId, parentId, initiatedBy, createdBy, traceId, spanId, taskId, escalationPayload, metadata, envelope, expiresAt, } = params;
1404
1404
  return [
1405
1405
  namespace ?? 'hmsh',
1406
1406
  appId ?? 'hmsh',
@@ -1421,6 +1421,7 @@ class PostgresStoreService extends __1.StoreService {
1421
1421
  createdBy ?? null,
1422
1422
  traceId ?? null,
1423
1423
  spanId ?? null,
1424
+ taskId ?? null,
1424
1425
  escalationPayload ? JSON.stringify(escalationPayload) : null,
1425
1426
  metadata ? JSON.stringify(metadata) : null,
1426
1427
  envelope ? JSON.stringify(envelope) : null,
@@ -1447,22 +1448,22 @@ class PostgresStoreService extends __1.StoreService {
1447
1448
  * rows that already exist are skipped and `null` is returned for them.
1448
1449
  */
1449
1450
  async createEscalationForMigration(params) {
1450
- const { id, namespace, appId, signalKey, topic, workflowId, taskQueue, workflowType, type, subtype, entity, description, role, priority, originId, parentId, initiatedBy, createdBy, traceId, spanId, escalationPayload, metadata, envelope, expiresAt, status, assignedTo, claimExpiresAt, claimedAt, resolvedAt, resolverPayload, milestones, createdAt, updatedAt, } = params;
1451
+ const { id, namespace, appId, signalKey, topic, workflowId, taskQueue, workflowType, type, subtype, entity, description, role, priority, originId, parentId, initiatedBy, createdBy, traceId, spanId, taskId, escalationPayload, metadata, envelope, expiresAt, status, assignedTo, claimExpiresAt, claimedAt, resolvedAt, resolverPayload, milestones, createdAt, updatedAt, } = params;
1451
1452
  const result = await this.pgClient.query(`
1452
1453
  INSERT INTO public.hmsh_escalations
1453
1454
  (id, namespace, app_id, signal_key, topic, workflow_id, task_queue, workflow_type,
1454
1455
  type, subtype, entity, description, role, priority,
1455
- origin_id, parent_id, initiated_by, created_by, trace_id, span_id,
1456
+ origin_id, parent_id, initiated_by, created_by, trace_id, span_id, task_id,
1456
1457
  escalation_payload, metadata, envelope, expires_at,
1457
1458
  status, assigned_to, claim_expires_at, claimed_at, resolved_at,
1458
1459
  resolver_payload, milestones, created_at, updated_at)
1459
1460
  VALUES
1460
1461
  ($1, $2, $3, $4, $5, $6, $7, $8,
1461
1462
  $9, $10, $11, $12, $13, $14,
1462
- $15, $16, $17, $18, $19, $20,
1463
- $21, $22, $23, $24,
1464
- $25, $26, $27, $28, $29,
1465
- $30, $31, $32, $33)
1463
+ $15, $16, $17, $18, $19, $20, $21,
1464
+ $22, $23, $24, $25,
1465
+ $26, $27, $28, $29, $30,
1466
+ $31, $32, $33, $34)
1466
1467
  ON CONFLICT (id) DO NOTHING
1467
1468
  RETURNING *
1468
1469
  `, [
@@ -1486,6 +1487,7 @@ class PostgresStoreService extends __1.StoreService {
1486
1487
  createdBy ?? null,
1487
1488
  traceId ?? null,
1488
1489
  spanId ?? null,
1490
+ taskId ?? null,
1489
1491
  escalationPayload ? JSON.stringify(escalationPayload) : null,
1490
1492
  metadata ? JSON.stringify(metadata) : null,
1491
1493
  envelope ? JSON.stringify(envelope) : null,
@@ -1510,16 +1512,15 @@ class PostgresStoreService extends __1.StoreService {
1510
1512
  const result = await this.pgClient.query(`SELECT * FROM public.hmsh_escalations WHERE signal_key = $1${namespace ? ' AND namespace = $2' : ''}`, namespace ? [signalKey, namespace] : [signalKey]);
1511
1513
  return result.rows[0] ?? null;
1512
1514
  }
1513
- async listEscalations(params = {}) {
1514
- const { namespace, role, roles, type, subtype, entity, status, assignedTo, workflowId, originId, available, sortBy, sortOrder, limit = 50, offset = 0 } = params;
1515
+ _escalationFilterConditions(params, startIdx = 1) {
1516
+ const { namespace, role, roles, type, subtype, entity, status, assignedTo, workflowId, originId, available, priority, metadata, ids, taskId } = params;
1515
1517
  const conditions = [];
1516
1518
  const values = [];
1517
- let idx = 1;
1519
+ let idx = startIdx;
1518
1520
  if (namespace) {
1519
1521
  conditions.push(`namespace = $${idx++}`);
1520
1522
  values.push(namespace);
1521
1523
  }
1522
- // roles[] takes precedence over role when both provided
1523
1524
  if (roles?.length) {
1524
1525
  conditions.push(`role = ANY($${idx++}::text[])`);
1525
1526
  values.push(roles);
@@ -1556,93 +1557,73 @@ class PostgresStoreService extends __1.StoreService {
1556
1557
  conditions.push(`origin_id = $${idx++}`);
1557
1558
  values.push(originId);
1558
1559
  }
1560
+ if (priority !== undefined) {
1561
+ conditions.push(`priority = $${idx++}`);
1562
+ values.push(priority);
1563
+ }
1564
+ if (metadata) {
1565
+ conditions.push(`metadata @> $${idx++}::jsonb`);
1566
+ values.push(JSON.stringify(metadata));
1567
+ }
1568
+ if (ids?.length) {
1569
+ conditions.push(`id = ANY($${idx++}::uuid[])`);
1570
+ values.push(ids);
1571
+ }
1572
+ if (taskId) {
1573
+ conditions.push(`task_id = $${idx++}`);
1574
+ values.push(taskId);
1575
+ }
1559
1576
  if (available === true) {
1560
1577
  conditions.push(`(assigned_to IS NULL OR (assigned_until IS NOT NULL AND assigned_until <= NOW()))`);
1561
1578
  }
1562
1579
  if (available === false) {
1563
1580
  conditions.push(`(assigned_to IS NOT NULL AND (assigned_until IS NULL OR assigned_until > NOW()))`);
1564
1581
  }
1582
+ return { conditions, values, idx };
1583
+ }
1584
+ async listEscalations(params = {}) {
1585
+ const { sortBy, sortOrder, orderBy: orderByParam, limit = 50, offset = 0 } = params;
1586
+ const { conditions, values, idx } = this._escalationFilterConditions(params);
1565
1587
  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}`;
1588
+ let orderByClause;
1589
+ if (orderByParam?.length) {
1590
+ const allowed = new Set(['priority', 'created_at', 'updated_at', 'resolved_at', 'role', 'type']);
1591
+ const parts = orderByParam
1592
+ .filter(o => allowed.has(o.column))
1593
+ .map(o => `${o.column} ${o.direction === 'desc' ? 'DESC' : 'ASC'}`);
1594
+ orderByClause = parts.length ? `ORDER BY ${parts.join(', ')}` : 'ORDER BY priority ASC, created_at DESC';
1595
+ }
1596
+ else {
1597
+ const col = sortBy === 'updated_at' ? 'updated_at' : sortBy === 'created_at' ? 'created_at' : 'priority';
1598
+ const dir = sortOrder === 'desc' ? 'DESC' : 'ASC';
1599
+ orderByClause = col === 'priority'
1600
+ ? `ORDER BY priority ${dir}, created_at DESC`
1601
+ : `ORDER BY ${col} ${dir}`;
1602
+ }
1572
1603
  values.push(limit, offset);
1573
1604
  const result = await this.pgClient.query(`SELECT *,
1574
1605
  (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);
1606
+ FROM public.hmsh_escalations ${where} ${orderByClause} LIMIT $${idx} OFFSET $${idx + 1}`, values);
1576
1607
  return result.rows;
1577
1608
  }
1578
1609
  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
- }
1610
+ const { conditions, values } = this._escalationFilterConditions(params);
1629
1611
  const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
1630
1612
  const result = await this.pgClient.query(`SELECT COUNT(*)::int AS total FROM public.hmsh_escalations ${where}`, values);
1631
1613
  return result.rows[0]?.total ?? 0;
1632
1614
  }
1633
1615
  async claimEscalation(params) {
1634
1616
  const { id, namespace, assignee = '', durationMinutes = 1 } = params;
1635
- // Claims are implicit: status stays 'pending'; availability = assigned_to IS NULL OR assigned_until <= NOW().
1636
1617
  // 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.
1618
+ // target: captures prior_assigned_to for isExtension; locks only when claimable.
1619
+ // claimed: writes both assigned_until and claim_expires_at (same value).
1639
1620
  const result = await this.pgClient.query(`
1640
1621
  WITH all_rows AS MATERIALIZED (
1641
1622
  SELECT id FROM public.hmsh_escalations
1642
1623
  WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''}
1643
1624
  ),
1644
1625
  target AS MATERIALIZED (
1645
- SELECT id FROM public.hmsh_escalations
1626
+ SELECT id, assigned_to AS prior_assigned_to FROM public.hmsh_escalations
1646
1627
  WHERE id = $1
1647
1628
  ${namespace ? 'AND namespace = $4' : ''}
1648
1629
  AND status = 'pending'
@@ -1658,34 +1639,40 @@ class PostgresStoreService extends __1.StoreService {
1658
1639
  assigned_until = NOW() + ($3 * INTERVAL '1 minute'),
1659
1640
  updated_at = NOW()
1660
1641
  FROM target WHERE public.hmsh_escalations.id = target.id
1661
- RETURNING public.hmsh_escalations.*
1642
+ RETURNING public.hmsh_escalations.*, target.prior_assigned_to
1662
1643
  )
1663
1644
  SELECT c.*, (SELECT EXISTS(SELECT 1 FROM all_rows)) AS row_exists
1664
1645
  FROM (VALUES(1)) AS dummy(v)
1665
1646
  LEFT JOIN claimed c ON true
1666
1647
  `, namespace ? [id, assignee, durationMinutes, namespace] : [id, assignee, durationMinutes]);
1667
1648
  const row = result.rows[0];
1668
- if (row?.id)
1669
- return { ok: true, entry: row };
1649
+ if (row?.id) {
1650
+ const isExtension = row.prior_assigned_to === assignee;
1651
+ return { ok: true, entry: row, isExtension };
1652
+ }
1670
1653
  if (row?.row_exists)
1671
1654
  return { ok: false, reason: 'conflict' };
1672
1655
  return { ok: false, reason: 'not-found' };
1673
1656
  }
1674
1657
  async claimEscalationByMetadata(params) {
1675
- const { key, value, namespace, assignee = '', durationMinutes = 1, roles } = params;
1658
+ const { key, value, namespace, assignee = '', durationMinutes = 1, roles, metadata: mergeMeta } = params;
1676
1659
  const filter = JSON.stringify({ [key]: value });
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().
1660
+ const mergeJson = mergeMeta ? JSON.stringify(mergeMeta) : null;
1661
+ // $1=filter $2=assignee $3=duration $4=roles $5=mergeJson [$6=namespace]
1662
+ const nsClause = namespace ? 'namespace = $6 AND' : '';
1663
+ const queryParams = namespace
1664
+ ? [filter, assignee, durationMinutes, roles ?? null, mergeJson, namespace]
1665
+ : [filter, assignee, durationMinutes, roles ?? null, mergeJson];
1679
1666
  const result = await this.pgClient.query(`
1680
1667
  WITH all_candidates AS MATERIALIZED (
1681
1668
  SELECT id FROM public.hmsh_escalations
1682
- WHERE ${namespace ? 'namespace = $5 AND' : ''}
1669
+ WHERE ${nsClause}
1683
1670
  metadata @> $1::jsonb
1684
1671
  AND ($4::text[] IS NULL OR role = ANY($4::text[]))
1685
1672
  ),
1686
1673
  target AS MATERIALIZED (
1687
1674
  SELECT id, assigned_to AS prior_assigned_to FROM public.hmsh_escalations
1688
- WHERE ${namespace ? 'namespace = $5 AND' : ''}
1675
+ WHERE ${nsClause}
1689
1676
  metadata @> $1::jsonb
1690
1677
  AND ($4::text[] IS NULL OR role = ANY($4::text[]))
1691
1678
  AND status = 'pending'
@@ -1700,6 +1687,9 @@ class PostgresStoreService extends __1.StoreService {
1700
1687
  claimed_at = NOW(),
1701
1688
  claim_expires_at = NOW() + ($3 * INTERVAL '1 minute'),
1702
1689
  assigned_until = NOW() + ($3 * INTERVAL '1 minute'),
1690
+ metadata = CASE WHEN $5::jsonb IS NOT NULL
1691
+ THEN COALESCE(public.hmsh_escalations.metadata, '{}'::jsonb) || $5::jsonb
1692
+ ELSE public.hmsh_escalations.metadata END,
1703
1693
  updated_at = NOW()
1704
1694
  FROM target WHERE public.hmsh_escalations.id = target.id
1705
1695
  RETURNING public.hmsh_escalations.*, target.prior_assigned_to
@@ -1707,9 +1697,7 @@ class PostgresStoreService extends __1.StoreService {
1707
1697
  SELECT c.*, (SELECT COUNT(*)::int FROM all_candidates) AS candidates_exist
1708
1698
  FROM (VALUES(1)) AS dummy(v)
1709
1699
  LEFT JOIN claimed c ON true
1710
- `, namespace
1711
- ? [filter, assignee, durationMinutes, roles ?? null, namespace]
1712
- : [filter, assignee, durationMinutes, roles ?? null]);
1700
+ `, queryParams);
1713
1701
  const row = result.rows[0];
1714
1702
  const candidatesExist = row?.candidates_exist ?? 0;
1715
1703
  if (row?.id) {
@@ -1724,9 +1712,6 @@ class PostgresStoreService extends __1.StoreService {
1724
1712
  }
1725
1713
  async releaseEscalation(params) {
1726
1714
  const { id, namespace, assignee } = params;
1727
- // Single CTE: SELECT FOR UPDATE locks the row, UPDATE releases it iff assignee matches.
1728
- // Left-join exposes target_id even when the UPDATE did not fire (wrong-assignee case).
1729
- // No second round-trip — assignee check and UPDATE are one atomic statement.
1730
1715
  const result = await this.pgClient.query(`
1731
1716
  WITH target AS MATERIALIZED (
1732
1717
  SELECT id, assigned_to FROM public.hmsh_escalations
@@ -1741,137 +1726,93 @@ class PostgresStoreService extends __1.StoreService {
1741
1726
  FROM target
1742
1727
  WHERE public.hmsh_escalations.id = target.id
1743
1728
  AND ($2::text IS NULL OR target.assigned_to = $2)
1744
- RETURNING public.hmsh_escalations.id
1729
+ RETURNING public.hmsh_escalations.*
1745
1730
  )
1746
- SELECT t.id AS target_id, t.assigned_to AS current_assignee, r.id AS released_id
1731
+ SELECT t.id AS target_id, t.assigned_to AS current_assignee,
1732
+ row_to_json(r)::jsonb AS entry_json
1747
1733
  FROM target t
1748
1734
  LEFT JOIN released r ON r.id = t.id
1749
1735
  `, namespace ? [id, assignee ?? null, namespace] : [id, assignee ?? null]);
1750
1736
  const row = result.rows[0];
1751
1737
  if (!row)
1752
1738
  return { ok: false, reason: 'not-found' };
1753
- if (!row.released_id)
1739
+ if (!row.entry_json)
1754
1740
  return { ok: false, reason: 'wrong-assignee' };
1755
- return { ok: true };
1741
+ return { ok: true, entry: row.entry_json };
1756
1742
  }
1757
1743
  async resolveEscalation(params) {
1758
1744
  const { id, namespace, resolverPayload } = params;
1759
1745
  const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1760
- const result = await this.pgClient.query(`
1761
- WITH target AS MATERIALIZED (
1762
- SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1763
- WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
1764
- LIMIT 1 FOR UPDATE
1765
- ),
1766
- resolved AS (
1767
- UPDATE public.hmsh_escalations
1768
- SET status = 'resolved',
1769
- resolved_at = NOW(),
1770
- resolver_payload = $2,
1771
- updated_at = NOW()
1772
- FROM target
1773
- WHERE public.hmsh_escalations.id = target.id
1774
- AND target.status = 'pending'
1775
- RETURNING public.hmsh_escalations.id
1776
- )
1777
- SELECT t.signal_key, t.topic, t.status AS prior_status,
1778
- CASE
1779
- WHEN r.id IS NOT NULL THEN 'resolved'
1780
- WHEN t.id IS NULL THEN 'not-found'
1781
- WHEN t.status = 'cancelled' THEN 'already-cancelled'
1782
- ELSE 'already-resolved'
1783
- END AS outcome
1784
- FROM (SELECT * FROM target) t
1785
- FULL OUTER JOIN (SELECT id FROM resolved) r ON r.id = t.id
1786
- `, namespace
1787
- ? [id, payloadJson, namespace]
1788
- : [id, payloadJson]);
1789
- if (!result.rows[0] || result.rows[0].outcome === 'not-found')
1790
- return { ok: false, reason: 'not-found' };
1791
- if (result.rows[0].outcome === 'already-cancelled')
1792
- return { ok: false, reason: 'already-cancelled' };
1793
- if (result.rows[0].outcome === 'already-resolved')
1794
- return { ok: false, reason: 'already-resolved' };
1795
- return { ok: true, signalKey: result.rows[0].signal_key, topic: result.rows[0].topic };
1746
+ // Explicit transaction: FOR UPDATE locks the row; WHERE guard on UPDATE is the TOCTOU
1747
+ // barrier a concurrent caller whose UPDATE matches 0 rows sees 'already-resolved'.
1748
+ // On crash before COMMIT neither write lands; on crash after COMMIT both are durable.
1749
+ // The resolved row's signal_key is the durable proof for recovery sweeps.
1750
+ await this.pgClient.query('BEGIN');
1751
+ try {
1752
+ const lockResult = await this.pgClient.query(`SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1753
+ WHERE id = $1 ${namespace ? 'AND namespace = $2' : ''} FOR UPDATE`, namespace ? [id, namespace] : [id]);
1754
+ if (!lockResult.rows[0]) {
1755
+ await this.pgClient.query('ROLLBACK');
1756
+ return { ok: false, reason: 'not-found' };
1757
+ }
1758
+ const { signal_key, topic, status } = lockResult.rows[0];
1759
+ if (status === 'cancelled') {
1760
+ await this.pgClient.query('ROLLBACK');
1761
+ return { ok: false, reason: 'already-cancelled' };
1762
+ }
1763
+ const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1764
+ SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2, updated_at = NOW()
1765
+ WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''} AND status = 'pending'
1766
+ RETURNING *`, namespace ? [id, payloadJson, namespace] : [id, payloadJson]);
1767
+ if (!updateResult.rows[0]) {
1768
+ await this.pgClient.query('ROLLBACK');
1769
+ return { ok: false, reason: 'already-resolved' };
1770
+ }
1771
+ await this.pgClient.query('COMMIT');
1772
+ return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic };
1773
+ }
1774
+ catch (e) {
1775
+ await this.pgClient.query('ROLLBACK');
1776
+ throw e;
1777
+ }
1796
1778
  }
1797
1779
  async resolveEscalationByMetadata(params) {
1798
1780
  const { key, value, namespace, resolverPayload, roles } = params;
1799
1781
  const filter = JSON.stringify({ [key]: value });
1800
1782
  const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1801
- const result = await this.pgClient.query(`
1802
- WITH target AS MATERIALIZED (
1803
- SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1804
- WHERE ${namespace ? 'namespace = $4 AND' : ''}
1805
- metadata @> $1::jsonb
1806
- AND ($3::text[] IS NULL OR role = ANY($3::text[]))
1807
- ORDER BY priority ASC, created_at ASC
1808
- LIMIT 1 FOR UPDATE
1809
- ),
1810
- resolved AS (
1811
- UPDATE public.hmsh_escalations
1812
- SET status = 'resolved',
1813
- resolved_at = NOW(),
1814
- resolver_payload = $2,
1815
- updated_at = NOW()
1816
- FROM target
1817
- WHERE public.hmsh_escalations.id = target.id
1818
- AND target.status = 'pending'
1819
- RETURNING public.hmsh_escalations.id
1820
- )
1821
- SELECT t.signal_key, t.topic, t.status AS prior_status,
1822
- CASE
1823
- WHEN r.id IS NOT NULL THEN 'resolved'
1824
- WHEN t.id IS NULL THEN 'not-found'
1825
- WHEN t.status = 'cancelled' THEN 'already-cancelled'
1826
- ELSE 'already-resolved'
1827
- END AS outcome
1828
- FROM (SELECT * FROM target) t
1829
- FULL OUTER JOIN (SELECT id FROM resolved) r ON r.id = t.id
1830
- `, namespace
1831
- ? [filter, payloadJson, roles ?? null, namespace]
1832
- : [filter, payloadJson, roles ?? null]);
1833
- if (!result.rows[0] || result.rows[0].outcome === 'not-found')
1834
- return { ok: false, reason: 'not-found' };
1835
- if (result.rows[0].outcome === 'already-cancelled')
1836
- return { ok: false, reason: 'already-cancelled' };
1837
- if (result.rows[0].outcome === 'already-resolved')
1838
- return { ok: false, reason: 'already-resolved' };
1839
- return { ok: true, signalKey: result.rows[0].signal_key, topic: result.rows[0].topic };
1840
- }
1841
- /**
1842
- * Queues the escalation UPDATE into an existing transaction without executing it.
1843
- * Used by client.ts resolve() to commit the status change atomically with signal delivery.
1844
- */
1845
- queueResolveEscalation(params, transaction) {
1846
- const { id, namespace, resolverPayload } = params;
1847
- const sql = namespace
1848
- ? `UPDATE public.hmsh_escalations
1849
- SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2, updated_at = NOW()
1850
- WHERE id = $1 AND namespace = $3 AND status IN ('pending', 'claimed')`
1851
- : `UPDATE public.hmsh_escalations
1783
+ await this.pgClient.query('BEGIN');
1784
+ try {
1785
+ const lockResult = await this.pgClient.query(`SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1786
+ WHERE ${namespace ? 'namespace = $3 AND' : ''}
1787
+ metadata @> $1::jsonb
1788
+ AND ($2::text[] IS NULL OR role = ANY($2::text[]))
1789
+ AND status IN ('pending', 'cancelled')
1790
+ ORDER BY priority ASC, created_at ASC
1791
+ LIMIT 1 FOR UPDATE`, namespace ? [filter, roles ?? null, namespace] : [filter, roles ?? null]);
1792
+ if (!lockResult.rows[0]) {
1793
+ await this.pgClient.query('ROLLBACK');
1794
+ return { ok: false, reason: 'not-found' };
1795
+ }
1796
+ const { id, signal_key, topic, status } = lockResult.rows[0];
1797
+ if (status === 'cancelled') {
1798
+ await this.pgClient.query('ROLLBACK');
1799
+ return { ok: false, reason: 'already-cancelled' };
1800
+ }
1801
+ const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1852
1802
  SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2, updated_at = NOW()
1853
- WHERE id = $1 AND status IN ('pending', 'claimed')`;
1854
- const sqlParams = namespace
1855
- ? [id, resolverPayload ? JSON.stringify(resolverPayload) : null, namespace]
1856
- : [id, resolverPayload ? JSON.stringify(resolverPayload) : null];
1857
- transaction.addCommand(sql, sqlParams, 'number');
1858
- }
1859
- /**
1860
- * Finds the highest-priority pending or claimed escalation matching the given metadata filter.
1861
- * Used by client.ts resolveByMetadata() to get routing info before building the atomic transaction.
1862
- */
1863
- async findEscalationByMetadata(key, value, roles, namespace) {
1864
- const filter = JSON.stringify({ [key]: value });
1865
- const result = await this.pgClient.query(`
1866
- SELECT * FROM public.hmsh_escalations
1867
- WHERE ${namespace ? 'namespace = $3 AND' : ''}
1868
- metadata @> $1::jsonb
1869
- AND ($2::text[] IS NULL OR role = ANY($2::text[]))
1870
- AND status IN ('pending', 'claimed')
1871
- ORDER BY priority ASC, created_at ASC
1872
- LIMIT 1
1873
- `, namespace ? [filter, roles, namespace] : [filter, roles]);
1874
- return result.rows[0] ?? null;
1803
+ WHERE id = $1 AND status = 'pending'
1804
+ RETURNING *`, [id, payloadJson]);
1805
+ if (!updateResult.rows[0]) {
1806
+ await this.pgClient.query('ROLLBACK');
1807
+ return { ok: false, reason: 'already-resolved' };
1808
+ }
1809
+ await this.pgClient.query('COMMIT');
1810
+ return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic };
1811
+ }
1812
+ catch (e) {
1813
+ await this.pgClient.query('ROLLBACK');
1814
+ throw e;
1815
+ }
1875
1816
  }
1876
1817
  async cancelEscalation(id, namespace) {
1877
1818
  const result = await this.pgClient.query(`
@@ -1921,7 +1862,7 @@ class PostgresStoreService extends __1.StoreService {
1921
1862
  return result.rows[0] ?? null;
1922
1863
  }
1923
1864
  async updateEscalation(params) {
1924
- const { id, namespace, description, priority, role, metadata, envelope, signalKey, topic, workflowId, taskQueue, workflowType, expiresAt } = params;
1865
+ const { id, namespace, description, priority, role, taskId, metadata, envelope, signalKey, topic, workflowId, taskQueue, workflowType, expiresAt } = params;
1925
1866
  const sets = [];
1926
1867
  const values = [id];
1927
1868
  let idx = 2;
@@ -1937,6 +1878,10 @@ class PostgresStoreService extends __1.StoreService {
1937
1878
  sets.push(`role = $${idx++}`);
1938
1879
  values.push(role);
1939
1880
  }
1881
+ if (taskId !== undefined) {
1882
+ sets.push(`task_id = $${idx++}`);
1883
+ values.push(taskId);
1884
+ }
1940
1885
  if (envelope !== undefined) {
1941
1886
  sets.push(`envelope = $${idx++}`);
1942
1887
  values.push(JSON.stringify(envelope));
@@ -1991,6 +1936,130 @@ class PostgresStoreService extends __1.StoreService {
1991
1936
  `, namespace ? [id, JSON.stringify(stamped), namespace] : [id, JSON.stringify(stamped)]);
1992
1937
  return result.rows[0] ?? null;
1993
1938
  }
1939
+ async claimManyEscalations(params) {
1940
+ const { ids, namespace, assignee, durationMinutes = 1 } = params;
1941
+ const result = await this.pgClient.query(`UPDATE public.hmsh_escalations
1942
+ SET assigned_to = $1,
1943
+ claimed_at = NOW(),
1944
+ claim_expires_at = NOW() + ($2 * INTERVAL '1 minute'),
1945
+ assigned_until = NOW() + ($2 * INTERVAL '1 minute'),
1946
+ updated_at = NOW()
1947
+ WHERE id = ANY($3::uuid[])
1948
+ ${namespace ? 'AND namespace = $4' : ''}
1949
+ AND status = 'pending'
1950
+ AND (assigned_to IS NULL OR assigned_until IS NULL OR assigned_until <= NOW() OR assigned_to = $1)`, namespace ? [assignee, durationMinutes, ids, namespace] : [assignee, durationMinutes, ids]);
1951
+ const claimed = result.rowCount ?? 0;
1952
+ return { claimed, skipped: ids.length - claimed };
1953
+ }
1954
+ async escalateManyEscalationsToRole(params) {
1955
+ const { ids, namespace, targetRole } = params;
1956
+ const result = await this.pgClient.query(`UPDATE public.hmsh_escalations
1957
+ SET role = $1,
1958
+ status = 'pending',
1959
+ assigned_to = NULL,
1960
+ assigned_until = NULL,
1961
+ claimed_at = NULL,
1962
+ claim_expires_at = NULL,
1963
+ updated_at = NOW()
1964
+ WHERE id = ANY($2::uuid[])
1965
+ ${namespace ? 'AND namespace = $3' : ''}
1966
+ AND status = 'pending'`, namespace ? [targetRole, ids, namespace] : [targetRole, ids]);
1967
+ return result.rowCount ?? 0;
1968
+ }
1969
+ async updateManyEscalationsPriority(params) {
1970
+ const { ids, namespace, priority } = params;
1971
+ const result = await this.pgClient.query(`UPDATE public.hmsh_escalations
1972
+ SET priority = $1, updated_at = NOW()
1973
+ WHERE id = ANY($2::uuid[])
1974
+ ${namespace ? 'AND namespace = $3' : ''}
1975
+ AND status = 'pending'`, namespace ? [priority, ids, namespace] : [priority, ids]);
1976
+ return result.rowCount ?? 0;
1977
+ }
1978
+ async resolveManyEscalations(params) {
1979
+ const { ids, namespace, resolverPayload } = params;
1980
+ const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1981
+ const result = await this.pgClient.query(`UPDATE public.hmsh_escalations
1982
+ SET status = 'resolved',
1983
+ resolved_at = NOW(),
1984
+ resolver_payload = $1,
1985
+ updated_at = NOW()
1986
+ WHERE id = ANY($2::uuid[])
1987
+ ${namespace ? 'AND namespace = $3' : ''}
1988
+ AND status = 'pending'
1989
+ RETURNING *`, namespace ? [payloadJson, ids, namespace] : [payloadJson, ids]);
1990
+ return result.rows;
1991
+ }
1992
+ async escalationStats(params = {}) {
1993
+ const { namespace, roles, period = '24h' } = params;
1994
+ if (roles !== undefined && roles.length === 0) {
1995
+ return { pending: 0, claimed: 0, created: 0, resolved: 0, by_role: [], by_type: [] };
1996
+ }
1997
+ const hoursMap = { '1h': 1, '24h': 24, '7d': 168, '30d': 720 };
1998
+ const hours = hoursMap[period] ?? 24;
1999
+ const queryParams = [hours];
2000
+ let idx = 2;
2001
+ const nsClause = namespace ? `AND namespace = $${idx++}` : '';
2002
+ const rolClause = roles?.length ? `AND role = ANY($${idx++}::text[])` : '';
2003
+ if (namespace)
2004
+ queryParams.push(namespace);
2005
+ if (roles?.length)
2006
+ queryParams.push(roles);
2007
+ const result = await this.pgClient.query(`
2008
+ WITH base AS (
2009
+ SELECT * FROM public.hmsh_escalations
2010
+ WHERE true ${nsClause} ${rolClause}
2011
+ ),
2012
+ totals AS (
2013
+ SELECT
2014
+ COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
2015
+ COUNT(*) FILTER (WHERE status = 'pending'
2016
+ AND assigned_to IS NOT NULL
2017
+ AND assigned_until IS NOT NULL AND assigned_until > NOW())::int AS claimed,
2018
+ COUNT(*) FILTER (WHERE created_at >= NOW() - ($1 * INTERVAL '1 hour'))::int AS created,
2019
+ COUNT(*) FILTER (WHERE resolved_at >= NOW() - ($1 * INTERVAL '1 hour'))::int AS resolved
2020
+ FROM base
2021
+ ),
2022
+ by_role AS (
2023
+ SELECT role,
2024
+ COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
2025
+ COUNT(*) FILTER (WHERE status = 'pending'
2026
+ AND assigned_to IS NOT NULL
2027
+ AND assigned_until IS NOT NULL AND assigned_until > NOW())::int AS claimed
2028
+ FROM base WHERE role IS NOT NULL
2029
+ GROUP BY role
2030
+ ),
2031
+ by_type AS (
2032
+ SELECT type,
2033
+ COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
2034
+ COUNT(*) FILTER (WHERE status = 'pending'
2035
+ AND assigned_to IS NOT NULL
2036
+ AND assigned_until IS NOT NULL AND assigned_until > NOW())::int AS claimed,
2037
+ COUNT(*) FILTER (WHERE resolved_at >= NOW() - ($1 * INTERVAL '1 hour'))::int AS resolved
2038
+ FROM base WHERE type IS NOT NULL
2039
+ GROUP BY type
2040
+ )
2041
+ SELECT
2042
+ t.pending, t.claimed, t.created, t.resolved,
2043
+ COALESCE((SELECT json_agg(json_build_object('role',role,'pending',pending,'claimed',claimed)) FROM by_role), '[]'::json) AS by_role,
2044
+ COALESCE((SELECT json_agg(json_build_object('type',type,'pending',pending,'claimed',claimed,'resolved',resolved)) FROM by_type), '[]'::json) AS by_type
2045
+ FROM totals t
2046
+ `, queryParams);
2047
+ const row = result.rows[0];
2048
+ return {
2049
+ pending: row.pending ?? 0,
2050
+ claimed: row.claimed ?? 0,
2051
+ created: row.created ?? 0,
2052
+ resolved: row.resolved ?? 0,
2053
+ by_role: row.by_role ?? [],
2054
+ by_type: row.by_type ?? [],
2055
+ };
2056
+ }
2057
+ async listDistinctEscalationTypes(namespace) {
2058
+ const result = await this.pgClient.query(`SELECT DISTINCT type FROM public.hmsh_escalations
2059
+ WHERE type IS NOT NULL ${namespace ? 'AND namespace = $1' : ''}
2060
+ ORDER BY type`, namespace ? [namespace] : []);
2061
+ return result.rows.map((r) => r.type);
2062
+ }
1994
2063
  async releaseExpiredEscalations(_namespace) {
1995
2064
  // No-op in the implicit claim model: availability is computed at query time from
1996
2065
  // assigned_until, so no background sweep is needed to release expired claims.