@hotmeshio/hotmesh 0.22.1 → 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,20 @@ 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, type, subtype, entity, status, assignedTo, workflowId, originId, 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
- if (role) {
1524
+ if (roles?.length) {
1525
+ conditions.push(`role = ANY($${idx++}::text[])`);
1526
+ values.push(roles);
1527
+ }
1528
+ else if (role) {
1523
1529
  conditions.push(`role = $${idx++}`);
1524
1530
  values.push(role);
1525
1531
  }
@@ -1551,97 +1557,153 @@ class PostgresStoreService extends __1.StoreService {
1551
1557
  conditions.push(`origin_id = $${idx++}`);
1552
1558
  values.push(originId);
1553
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
+ }
1576
+ if (available === true) {
1577
+ conditions.push(`(assigned_to IS NULL OR (assigned_until IS NOT NULL AND assigned_until <= NOW()))`);
1578
+ }
1579
+ if (available === false) {
1580
+ conditions.push(`(assigned_to IS NOT NULL AND (assigned_until IS NULL OR assigned_until > NOW()))`);
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);
1554
1587
  const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
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
+ }
1555
1603
  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);
1604
+ const result = await this.pgClient.query(`SELECT *,
1605
+ (assigned_to IS NULL OR (assigned_until IS NOT NULL AND assigned_until <= NOW())) AS available
1606
+ FROM public.hmsh_escalations ${where} ${orderByClause} LIMIT $${idx} OFFSET $${idx + 1}`, values);
1557
1607
  return result.rows;
1558
1608
  }
1609
+ async countEscalations(params = {}) {
1610
+ const { conditions, values } = this._escalationFilterConditions(params);
1611
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
1612
+ const result = await this.pgClient.query(`SELECT COUNT(*)::int AS total FROM public.hmsh_escalations ${where}`, values);
1613
+ return result.rows[0]?.total ?? 0;
1614
+ }
1559
1615
  async claimEscalation(params) {
1560
1616
  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.
1617
+ // all_rows: unlocked read to distinguish 'not-found' vs 'conflict'.
1618
+ // target: captures prior_assigned_to for isExtension; locks only when claimable.
1619
+ // claimed: writes both assigned_until and claim_expires_at (same value).
1565
1620
  const result = await this.pgClient.query(`
1566
1621
  WITH all_rows AS MATERIALIZED (
1567
1622
  SELECT id FROM public.hmsh_escalations
1568
1623
  WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''}
1569
1624
  ),
1570
1625
  target AS MATERIALIZED (
1571
- SELECT id FROM public.hmsh_escalations
1626
+ SELECT id, assigned_to AS prior_assigned_to FROM public.hmsh_escalations
1572
1627
  WHERE id = $1
1573
1628
  ${namespace ? 'AND namespace = $4' : ''}
1574
1629
  AND status = 'pending'
1575
- AND (assigned_to IS NULL OR claim_expires_at <= NOW() OR assigned_to = $2)
1630
+ AND (assigned_to IS NULL OR assigned_until IS NULL OR assigned_until <= NOW() OR assigned_to = $2)
1576
1631
  LIMIT 1
1577
1632
  FOR UPDATE SKIP LOCKED
1578
1633
  ),
1579
1634
  claimed AS (
1580
1635
  UPDATE public.hmsh_escalations
1581
- SET status = 'claimed',
1582
- assigned_to = $2,
1636
+ SET assigned_to = $2,
1583
1637
  claimed_at = NOW(),
1584
1638
  claim_expires_at = NOW() + ($3 * INTERVAL '1 minute'),
1639
+ assigned_until = NOW() + ($3 * INTERVAL '1 minute'),
1585
1640
  updated_at = NOW()
1586
1641
  FROM target WHERE public.hmsh_escalations.id = target.id
1587
- RETURNING public.hmsh_escalations.*
1642
+ RETURNING public.hmsh_escalations.*, target.prior_assigned_to
1588
1643
  )
1589
1644
  SELECT c.*, (SELECT EXISTS(SELECT 1 FROM all_rows)) AS row_exists
1590
1645
  FROM (VALUES(1)) AS dummy(v)
1591
1646
  LEFT JOIN claimed c ON true
1592
1647
  `, namespace ? [id, assignee, durationMinutes, namespace] : [id, assignee, durationMinutes]);
1593
1648
  const row = result.rows[0];
1594
- if (row?.id)
1595
- 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
+ }
1596
1653
  if (row?.row_exists)
1597
1654
  return { ok: false, reason: 'conflict' };
1598
1655
  return { ok: false, reason: 'not-found' };
1599
1656
  }
1600
1657
  async claimEscalationByMetadata(params) {
1601
- const { key, value, namespace, assignee = '', durationMinutes = 1, roles } = params;
1658
+ const { key, value, namespace, assignee = '', durationMinutes = 1, roles, metadata: mergeMeta } = params;
1602
1659
  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.
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];
1607
1666
  const result = await this.pgClient.query(`
1608
1667
  WITH all_candidates AS MATERIALIZED (
1609
1668
  SELECT id FROM public.hmsh_escalations
1610
- WHERE ${namespace ? 'namespace = $5 AND' : ''}
1669
+ WHERE ${nsClause}
1611
1670
  metadata @> $1::jsonb
1612
1671
  AND ($4::text[] IS NULL OR role = ANY($4::text[]))
1613
1672
  ),
1614
1673
  target AS MATERIALIZED (
1615
- SELECT id FROM public.hmsh_escalations
1616
- WHERE ${namespace ? 'namespace = $5 AND' : ''}
1674
+ SELECT id, assigned_to AS prior_assigned_to FROM public.hmsh_escalations
1675
+ WHERE ${nsClause}
1617
1676
  metadata @> $1::jsonb
1618
1677
  AND ($4::text[] IS NULL OR role = ANY($4::text[]))
1619
1678
  AND status = 'pending'
1620
- AND (assigned_to IS NULL OR claim_expires_at <= NOW() OR assigned_to = $2)
1679
+ AND (assigned_to IS NULL OR assigned_until IS NULL OR assigned_until <= NOW() OR assigned_to = $2)
1621
1680
  ORDER BY priority ASC, created_at ASC
1622
1681
  LIMIT 1
1623
1682
  FOR UPDATE SKIP LOCKED
1624
1683
  ),
1625
1684
  claimed AS (
1626
1685
  UPDATE public.hmsh_escalations
1627
- SET status = 'claimed',
1628
- assigned_to = $2,
1686
+ SET assigned_to = $2,
1629
1687
  claimed_at = NOW(),
1630
1688
  claim_expires_at = NOW() + ($3 * INTERVAL '1 minute'),
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,
1631
1693
  updated_at = NOW()
1632
1694
  FROM target WHERE public.hmsh_escalations.id = target.id
1633
- RETURNING public.hmsh_escalations.*
1695
+ RETURNING public.hmsh_escalations.*, target.prior_assigned_to
1634
1696
  )
1635
1697
  SELECT c.*, (SELECT COUNT(*)::int FROM all_candidates) AS candidates_exist
1636
1698
  FROM (VALUES(1)) AS dummy(v)
1637
1699
  LEFT JOIN claimed c ON true
1638
- `, namespace
1639
- ? [filter, assignee, durationMinutes, roles ?? null, namespace]
1640
- : [filter, assignee, durationMinutes, roles ?? null]);
1700
+ `, queryParams);
1641
1701
  const row = result.rows[0];
1642
1702
  const candidatesExist = row?.candidates_exist ?? 0;
1643
- if (row?.id)
1644
- return { ok: true, entry: row, candidatesExist };
1703
+ if (row?.id) {
1704
+ const isExtension = row.prior_assigned_to === assignee;
1705
+ return { ok: true, entry: row, candidatesExist, isExtension };
1706
+ }
1645
1707
  return {
1646
1708
  ok: false,
1647
1709
  reason: candidatesExist > 0 ? 'conflict' : 'not-found',
@@ -1650,14 +1712,11 @@ class PostgresStoreService extends __1.StoreService {
1650
1712
  }
1651
1713
  async releaseEscalation(params) {
1652
1714
  const { id, namespace, assignee } = params;
1653
- // Single CTE: SELECT FOR UPDATE locks the row, UPDATE releases it iff assignee matches.
1654
- // Left-join exposes target_id even when the UPDATE did not fire (wrong-assignee case).
1655
- // No second round-trip — assignee check and UPDATE are one atomic statement.
1656
1715
  const result = await this.pgClient.query(`
1657
1716
  WITH target AS MATERIALIZED (
1658
1717
  SELECT id, assigned_to FROM public.hmsh_escalations
1659
1718
  WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
1660
- AND status = 'claimed'
1719
+ AND status = 'pending' AND assigned_to IS NOT NULL
1661
1720
  FOR UPDATE
1662
1721
  ),
1663
1722
  released AS (
@@ -1667,135 +1726,93 @@ class PostgresStoreService extends __1.StoreService {
1667
1726
  FROM target
1668
1727
  WHERE public.hmsh_escalations.id = target.id
1669
1728
  AND ($2::text IS NULL OR target.assigned_to = $2)
1670
- RETURNING public.hmsh_escalations.id
1729
+ RETURNING public.hmsh_escalations.*
1671
1730
  )
1672
- 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
1673
1733
  FROM target t
1674
1734
  LEFT JOIN released r ON r.id = t.id
1675
1735
  `, namespace ? [id, assignee ?? null, namespace] : [id, assignee ?? null]);
1676
1736
  const row = result.rows[0];
1677
1737
  if (!row)
1678
1738
  return { ok: false, reason: 'not-found' };
1679
- if (!row.released_id)
1739
+ if (!row.entry_json)
1680
1740
  return { ok: false, reason: 'wrong-assignee' };
1681
- return { ok: true };
1741
+ return { ok: true, entry: row.entry_json };
1682
1742
  }
1683
1743
  async resolveEscalation(params) {
1684
1744
  const { id, namespace, resolverPayload } = params;
1685
- const result = await this.pgClient.query(`
1686
- WITH target AS MATERIALIZED (
1687
- SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1688
- WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
1689
- LIMIT 1 FOR UPDATE
1690
- ),
1691
- resolved AS (
1692
- UPDATE public.hmsh_escalations
1693
- SET status = 'resolved',
1694
- resolved_at = NOW(),
1695
- resolver_payload = $2,
1696
- updated_at = NOW()
1697
- FROM target
1698
- WHERE public.hmsh_escalations.id = target.id
1699
- AND target.status IN ('pending', 'claimed')
1700
- RETURNING public.hmsh_escalations.id
1701
- )
1702
- SELECT t.signal_key, t.topic, t.status AS prior_status,
1703
- CASE
1704
- WHEN r.id IS NOT NULL THEN 'resolved'
1705
- WHEN t.id IS NULL THEN 'not-found'
1706
- WHEN t.status = 'cancelled' THEN 'already-cancelled'
1707
- ELSE 'already-resolved'
1708
- END AS outcome
1709
- FROM (SELECT * FROM target) t
1710
- FULL OUTER JOIN (SELECT id FROM resolved) r ON r.id = t.id
1711
- `, namespace
1712
- ? [id, resolverPayload ? JSON.stringify(resolverPayload) : null, namespace]
1713
- : [id, resolverPayload ? JSON.stringify(resolverPayload) : null]);
1714
- if (!result.rows[0] || result.rows[0].outcome === 'not-found')
1715
- return { ok: false, reason: 'not-found' };
1716
- if (result.rows[0].outcome === 'already-cancelled')
1717
- return { ok: false, reason: 'already-cancelled' };
1718
- if (result.rows[0].outcome === 'already-resolved')
1719
- return { ok: false, reason: 'already-resolved' };
1720
- return { ok: true, signalKey: result.rows[0].signal_key, topic: result.rows[0].topic };
1745
+ const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
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
+ }
1721
1778
  }
1722
1779
  async resolveEscalationByMetadata(params) {
1723
1780
  const { key, value, namespace, resolverPayload, roles } = params;
1724
1781
  const filter = JSON.stringify({ [key]: value });
1725
- const result = await this.pgClient.query(`
1726
- WITH target AS MATERIALIZED (
1727
- SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1728
- WHERE ${namespace ? 'namespace = $4 AND' : ''}
1729
- metadata @> $1::jsonb
1730
- AND ($3::text[] IS NULL OR role = ANY($3::text[]))
1731
- ORDER BY priority ASC, created_at ASC
1732
- LIMIT 1 FOR UPDATE
1733
- ),
1734
- resolved AS (
1735
- UPDATE public.hmsh_escalations
1736
- SET status = 'resolved',
1737
- resolved_at = NOW(),
1738
- resolver_payload = $2,
1739
- updated_at = NOW()
1740
- FROM target
1741
- WHERE public.hmsh_escalations.id = target.id
1742
- AND target.status IN ('pending', 'claimed')
1743
- RETURNING public.hmsh_escalations.id
1744
- )
1745
- SELECT t.signal_key, t.topic, t.status AS prior_status,
1746
- CASE
1747
- WHEN r.id IS NOT NULL THEN 'resolved'
1748
- WHEN t.id IS NULL THEN 'not-found'
1749
- WHEN t.status = 'cancelled' THEN 'already-cancelled'
1750
- ELSE 'already-resolved'
1751
- END AS outcome
1752
- FROM (SELECT * FROM target) t
1753
- FULL OUTER JOIN (SELECT id FROM resolved) r ON r.id = t.id
1754
- `, namespace
1755
- ? [filter, resolverPayload ? JSON.stringify(resolverPayload) : null, roles ?? null, namespace]
1756
- : [filter, resolverPayload ? JSON.stringify(resolverPayload) : null, roles ?? null]);
1757
- if (!result.rows[0] || result.rows[0].outcome === 'not-found')
1758
- return { ok: false, reason: 'not-found' };
1759
- if (result.rows[0].outcome === 'already-cancelled')
1760
- return { ok: false, reason: 'already-cancelled' };
1761
- if (result.rows[0].outcome === 'already-resolved')
1762
- return { ok: false, reason: 'already-resolved' };
1763
- return { ok: true, signalKey: result.rows[0].signal_key, topic: result.rows[0].topic };
1764
- }
1765
- /**
1766
- * Queues the escalation UPDATE into an existing transaction without executing it.
1767
- * Used by client.ts resolve() to commit the status change atomically with signal delivery.
1768
- */
1769
- queueResolveEscalation(params, transaction) {
1770
- const { id, namespace, resolverPayload } = params;
1771
- const sql = namespace
1772
- ? `UPDATE public.hmsh_escalations
1773
- SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2, updated_at = NOW()
1774
- WHERE id = $1 AND namespace = $3 AND status IN ('pending', 'claimed')`
1775
- : `UPDATE public.hmsh_escalations
1782
+ const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
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
1776
1802
  SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2, updated_at = NOW()
1777
- WHERE id = $1 AND status IN ('pending', 'claimed')`;
1778
- const sqlParams = namespace
1779
- ? [id, resolverPayload ? JSON.stringify(resolverPayload) : null, namespace]
1780
- : [id, resolverPayload ? JSON.stringify(resolverPayload) : null];
1781
- transaction.addCommand(sql, sqlParams, 'number');
1782
- }
1783
- /**
1784
- * Finds the highest-priority pending or claimed escalation matching the given metadata filter.
1785
- * Used by client.ts resolveByMetadata() to get routing info before building the atomic transaction.
1786
- */
1787
- async findEscalationByMetadata(key, value, roles, namespace) {
1788
- const filter = JSON.stringify({ [key]: value });
1789
- const result = await this.pgClient.query(`
1790
- SELECT * FROM public.hmsh_escalations
1791
- WHERE ${namespace ? 'namespace = $3 AND' : ''}
1792
- metadata @> $1::jsonb
1793
- AND ($2::text[] IS NULL OR role = ANY($2::text[]))
1794
- AND status IN ('pending', 'claimed')
1795
- ORDER BY priority ASC, created_at ASC
1796
- LIMIT 1
1797
- `, namespace ? [filter, roles, namespace] : [filter, roles]);
1798
- 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
+ }
1799
1816
  }
1800
1817
  async cancelEscalation(id, namespace) {
1801
1818
  const result = await this.pgClient.query(`
@@ -1809,7 +1826,7 @@ class PostgresStoreService extends __1.StoreService {
1809
1826
  SET status = 'cancelled', updated_at = NOW()
1810
1827
  FROM target
1811
1828
  WHERE public.hmsh_escalations.id = target.id
1812
- AND target.status IN ('pending', 'claimed')
1829
+ AND target.status = 'pending'
1813
1830
  RETURNING public.hmsh_escalations.id
1814
1831
  )
1815
1832
  SELECT t.id, t.status AS prior_status,
@@ -1839,13 +1856,13 @@ class PostgresStoreService extends __1.StoreService {
1839
1856
  claim_expires_at = NULL,
1840
1857
  updated_at = NOW()
1841
1858
  WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
1842
- AND status IN ('pending', 'claimed')
1859
+ AND status = 'pending'
1843
1860
  RETURNING *
1844
1861
  `, namespace ? [id, targetRole, namespace] : [id, targetRole]);
1845
1862
  return result.rows[0] ?? null;
1846
1863
  }
1847
1864
  async updateEscalation(params) {
1848
- 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;
1849
1866
  const sets = [];
1850
1867
  const values = [id];
1851
1868
  let idx = 2;
@@ -1861,6 +1878,10 @@ class PostgresStoreService extends __1.StoreService {
1861
1878
  sets.push(`role = $${idx++}`);
1862
1879
  values.push(role);
1863
1880
  }
1881
+ if (taskId !== undefined) {
1882
+ sets.push(`task_id = $${idx++}`);
1883
+ values.push(taskId);
1884
+ }
1864
1885
  if (envelope !== undefined) {
1865
1886
  sets.push(`envelope = $${idx++}`);
1866
1887
  values.push(JSON.stringify(envelope));
@@ -1915,16 +1936,134 @@ class PostgresStoreService extends __1.StoreService {
1915
1936
  `, namespace ? [id, JSON.stringify(stamped), namespace] : [id, JSON.stringify(stamped)]);
1916
1937
  return result.rows[0] ?? null;
1917
1938
  }
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] : []);
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]);
1927
1976
  return result.rowCount ?? 0;
1928
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
+ }
2063
+ async releaseExpiredEscalations(_namespace) {
2064
+ // No-op in the implicit claim model: availability is computed at query time from
2065
+ // assigned_until, so no background sweep is needed to release expired claims.
2066
+ return 0;
2067
+ }
1929
2068
  }
1930
2069
  exports.PostgresStoreService = PostgresStoreService;