@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.
- package/build/index.d.ts +2 -1
- package/build/index.js +3 -1
- package/build/package.json +2 -1
- package/build/services/durable/client.d.ts +8 -273
- package/build/services/durable/client.js +5 -384
- package/build/services/escalations/client.d.ts +168 -0
- package/build/services/escalations/client.js +320 -0
- package/build/services/escalations/index.d.ts +66 -0
- package/build/services/escalations/index.js +71 -0
- package/build/services/store/providers/postgres/kvtables.js +153 -6
- package/build/services/store/providers/postgres/postgres.d.ts +16 -21
- package/build/services/store/providers/postgres/postgres.js +310 -171
- package/build/types/hmsh_escalations.d.ts +85 -6
- package/index.ts +2 -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,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
|
-
|
|
1514
|
-
const { namespace, role, type, subtype, entity, status, assignedTo, workflowId, originId,
|
|
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
|
-
if (
|
|
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
|
|
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:
|
|
1562
|
-
// target:
|
|
1563
|
-
// claimed:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1604
|
-
//
|
|
1605
|
-
|
|
1606
|
-
|
|
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 ${
|
|
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 ${
|
|
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
|
|
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
|
|
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
|
-
`,
|
|
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
|
-
|
|
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 = '
|
|
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
|
|
1729
|
+
RETURNING public.hmsh_escalations.*
|
|
1671
1730
|
)
|
|
1672
|
-
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
|
|
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.
|
|
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
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
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
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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
|
|
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
|
|
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
|
|
1919
|
-
const
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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;
|