@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.
- package/build/package.json +2 -1
- package/build/services/escalations/client.d.ts +50 -12
- package/build/services/escalations/client.js +70 -12
- package/build/services/store/providers/postgres/kvtables.js +153 -6
- package/build/services/store/providers/postgres/postgres.d.ts +10 -16
- package/build/services/store/providers/postgres/postgres.js +271 -202
- package/build/types/hmsh_escalations.d.ts +70 -0
- package/package.json +2 -2
|
@@ -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
|
-
$
|
|
1464
|
-
$
|
|
1465
|
-
$
|
|
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
|
-
|
|
1514
|
-
const { namespace, role, roles, type, subtype, entity, status, assignedTo, workflowId, originId, available,
|
|
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 =
|
|
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
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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} ${
|
|
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 {
|
|
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:
|
|
1638
|
-
// claimed: writes both assigned_until and claim_expires_at (same value)
|
|
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
|
-
|
|
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
|
-
|
|
1678
|
-
//
|
|
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 ${
|
|
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 ${
|
|
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
|
-
`,
|
|
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
|
|
1729
|
+
RETURNING public.hmsh_escalations.*
|
|
1745
1730
|
)
|
|
1746
|
-
SELECT t.id AS target_id, t.assigned_to AS current_assignee,
|
|
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.
|
|
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
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
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
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
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.
|