@hotmeshio/hotmesh 0.21.1 → 0.22.1
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/README.md +12 -129
- package/build/modules/utils.d.ts +2 -0
- package/build/modules/utils.js +9 -1
- package/build/package.json +8 -2
- package/build/services/activities/hook.d.ts +178 -58
- package/build/services/activities/hook.js +244 -58
- package/build/services/activities/trigger.js +5 -1
- package/build/services/durable/client.d.ts +273 -67
- package/build/services/durable/client.js +351 -126
- package/build/services/durable/index.d.ts +7 -3
- package/build/services/durable/index.js +6 -0
- package/build/services/durable/schemas/factory.js +40 -0
- package/build/services/durable/worker.js +5 -28
- package/build/services/durable/workflow/condition.d.ts +69 -37
- package/build/services/durable/workflow/condition.js +70 -39
- package/build/services/hotmesh/index.d.ts +31 -4
- package/build/services/hotmesh/index.js +31 -4
- package/build/services/store/index.d.ts +1 -1
- package/build/services/store/providers/postgres/kvsql.d.ts +1 -1
- package/build/services/store/providers/postgres/kvtables.js +83 -122
- package/build/services/store/providers/postgres/kvtypes/hash/basic.d.ts +1 -1
- package/build/services/store/providers/postgres/kvtypes/hash/basic.js +8 -8
- package/build/services/store/providers/postgres/kvtypes/hash/index.d.ts +1 -1
- package/build/services/store/providers/postgres/postgres.d.ts +51 -188
- package/build/services/store/providers/postgres/postgres.js +542 -285
- package/build/types/activity.d.ts +2 -0
- package/build/types/hmsh_escalations.d.ts +240 -0
- package/build/types/index.d.ts +1 -1
- package/build/types/provider.d.ts +2 -0
- package/package.json +9 -2
- package/build/types/signal.d.ts +0 -147
- /package/build/types/{signal.js → hmsh_escalations.js} +0 -0
|
@@ -43,6 +43,19 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
43
43
|
constructor(storeClient) {
|
|
44
44
|
super(storeClient);
|
|
45
45
|
this.isScout = false;
|
|
46
|
+
// ─── hmsh_escalations ────────────────────────────────────────────────────────
|
|
47
|
+
this._escalationInsertSql = `
|
|
48
|
+
INSERT INTO public.hmsh_escalations
|
|
49
|
+
(namespace, app_id, signal_key, topic, workflow_id, task_queue, workflow_type,
|
|
50
|
+
type, subtype, entity, description, role, priority,
|
|
51
|
+
origin_id, parent_id, initiated_by, created_by, trace_id, span_id,
|
|
52
|
+
escalation_payload, metadata, envelope, expires_at)
|
|
53
|
+
VALUES
|
|
54
|
+
($1, $2, $3, $4, $5, $6, $7,
|
|
55
|
+
$8, $9, $10, $11, $12, $13,
|
|
56
|
+
$14, $15, $16, $17, $18, $19,
|
|
57
|
+
$20, $21, $22, $23)
|
|
58
|
+
ON CONFLICT (namespace, app_id, signal_key) WHERE signal_key IS NOT NULL DO NOTHING`;
|
|
46
59
|
//Instead of directly referencing the 'pg' package and methods like 'query',
|
|
47
60
|
// the PostgresStore wraps the 'pg' client in a class that implements
|
|
48
61
|
// an entity/attribute interface.
|
|
@@ -601,9 +614,9 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
601
614
|
});
|
|
602
615
|
return await this.kvsql(transaction).hincrbyfloat(jobKey, guid, amount);
|
|
603
616
|
}
|
|
604
|
-
async setStateNX(jobId, appId, status, entity, transaction) {
|
|
617
|
+
async setStateNX(jobId, appId, status, entity, transaction, originId, parentId) {
|
|
605
618
|
const hashKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId, jobId });
|
|
606
|
-
const result = await this.kvsql().hsetnx(hashKey, ':', status?.toString() ?? '1', transaction, entity);
|
|
619
|
+
const result = await this.kvsql().hsetnx(hashKey, ':', status?.toString() ?? '1', transaction, entity, originId, parentId);
|
|
607
620
|
if (transaction)
|
|
608
621
|
return result;
|
|
609
622
|
return this.isSuccessful(result);
|
|
@@ -1368,289 +1381,6 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1368
1381
|
};
|
|
1369
1382
|
});
|
|
1370
1383
|
}
|
|
1371
|
-
// ─── Signal Queue Methods ─────────────────────────────────────────────────
|
|
1372
|
-
signalQueueTable() {
|
|
1373
|
-
return `${this.kvsql().safeName(this.appId)}.hotmesh_signals`;
|
|
1374
|
-
}
|
|
1375
|
-
rowToSignalEntry(row) {
|
|
1376
|
-
return {
|
|
1377
|
-
id: row.id,
|
|
1378
|
-
namespace: row.namespace,
|
|
1379
|
-
appId: row.app_id,
|
|
1380
|
-
signalKey: row.signal_key,
|
|
1381
|
-
workflowId: row.workflow_id,
|
|
1382
|
-
jobId: row.job_id,
|
|
1383
|
-
topic: row.topic,
|
|
1384
|
-
status: row.status,
|
|
1385
|
-
role: row.role,
|
|
1386
|
-
type: row.type,
|
|
1387
|
-
subtype: row.subtype,
|
|
1388
|
-
priority: row.priority,
|
|
1389
|
-
description: row.description,
|
|
1390
|
-
taskQueue: row.task_queue,
|
|
1391
|
-
workflowType: row.workflow_type,
|
|
1392
|
-
assignedTo: row.assigned_to,
|
|
1393
|
-
claimedAt: row.claimed_at ? new Date(row.claimed_at) : undefined,
|
|
1394
|
-
claimExpiresAt: row.claim_expires_at ? new Date(row.claim_expires_at) : undefined,
|
|
1395
|
-
resolvedAt: row.resolved_at ? new Date(row.resolved_at) : undefined,
|
|
1396
|
-
resolverPayload: row.resolver_payload,
|
|
1397
|
-
envelope: row.envelope,
|
|
1398
|
-
metadata: row.metadata,
|
|
1399
|
-
expiresAt: row.expires_at ? new Date(row.expires_at) : undefined,
|
|
1400
|
-
createdAt: new Date(row.created_at),
|
|
1401
|
-
updatedAt: new Date(row.updated_at),
|
|
1402
|
-
};
|
|
1403
|
-
}
|
|
1404
|
-
async enqueueSignal(params) {
|
|
1405
|
-
const tbl = this.signalQueueTable();
|
|
1406
|
-
const result = await this.pgClient.query(`INSERT INTO ${tbl}
|
|
1407
|
-
(namespace, app_id, signal_key, workflow_id, job_id, topic,
|
|
1408
|
-
role, type, subtype, priority, description,
|
|
1409
|
-
task_queue, workflow_type, assigned_to,
|
|
1410
|
-
metadata, envelope, expires_at)
|
|
1411
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
|
1412
|
-
ON CONFLICT (namespace, app_id, signal_key) DO NOTHING
|
|
1413
|
-
RETURNING id`, [
|
|
1414
|
-
params.namespace,
|
|
1415
|
-
params.appId,
|
|
1416
|
-
params.signalKey,
|
|
1417
|
-
params.workflowId,
|
|
1418
|
-
params.jobId ?? null,
|
|
1419
|
-
params.topic ?? null,
|
|
1420
|
-
params.role ?? null,
|
|
1421
|
-
params.type ?? null,
|
|
1422
|
-
params.subtype ?? null,
|
|
1423
|
-
params.priority ?? 5,
|
|
1424
|
-
params.description ?? null,
|
|
1425
|
-
params.taskQueue ?? null,
|
|
1426
|
-
params.workflowType ?? null,
|
|
1427
|
-
params.assignedTo ?? null,
|
|
1428
|
-
params.metadata ? JSON.stringify(params.metadata) : null,
|
|
1429
|
-
params.envelope ? JSON.stringify(params.envelope) : null,
|
|
1430
|
-
params.expiresAt ?? null,
|
|
1431
|
-
]);
|
|
1432
|
-
if (result.rows.length === 0)
|
|
1433
|
-
return null;
|
|
1434
|
-
return { id: result.rows[0].id };
|
|
1435
|
-
}
|
|
1436
|
-
async claimSignal(params) {
|
|
1437
|
-
const tbl = this.signalQueueTable();
|
|
1438
|
-
const minutes = params.durationMinutes ?? 30;
|
|
1439
|
-
const result = await this.pgClient.query(`WITH found AS (
|
|
1440
|
-
SELECT id, status FROM ${tbl}
|
|
1441
|
-
WHERE namespace = $1 AND app_id = $2 AND id = $4
|
|
1442
|
-
),
|
|
1443
|
-
claimed AS (
|
|
1444
|
-
UPDATE ${tbl}
|
|
1445
|
-
SET status = 'claimed',
|
|
1446
|
-
claimed_at = NOW(),
|
|
1447
|
-
claim_expires_at = NOW() + INTERVAL '${minutes} minutes',
|
|
1448
|
-
assigned_to = COALESCE($3, assigned_to),
|
|
1449
|
-
updated_at = NOW()
|
|
1450
|
-
WHERE namespace = $1 AND app_id = $2
|
|
1451
|
-
AND id = $4
|
|
1452
|
-
AND status = 'pending'
|
|
1453
|
-
RETURNING *
|
|
1454
|
-
)
|
|
1455
|
-
SELECT c.*, f.id AS f_id
|
|
1456
|
-
FROM found f
|
|
1457
|
-
LEFT JOIN claimed c ON c.id = f.id`, [params.namespace, params.appId, params.assignee ?? null, params.id]);
|
|
1458
|
-
if (result.rows.length === 0 || !result.rows[0].f_id) {
|
|
1459
|
-
return { ok: false, reason: 'not-found' };
|
|
1460
|
-
}
|
|
1461
|
-
if (!result.rows[0].id) {
|
|
1462
|
-
return { ok: false, reason: 'conflict' };
|
|
1463
|
-
}
|
|
1464
|
-
return { ok: true, entry: this.rowToSignalEntry(result.rows[0]) };
|
|
1465
|
-
}
|
|
1466
|
-
async claimSignalByMetadata(params) {
|
|
1467
|
-
const tbl = this.signalQueueTable();
|
|
1468
|
-
const minutes = params.durationMinutes ?? 30;
|
|
1469
|
-
const containsJson = JSON.stringify({ [params.key]: params.value });
|
|
1470
|
-
const result = await this.pgClient.query(`WITH found AS (
|
|
1471
|
-
SELECT id, status FROM ${tbl}
|
|
1472
|
-
WHERE namespace = $1 AND app_id = $2
|
|
1473
|
-
AND metadata @> $4::jsonb
|
|
1474
|
-
ORDER BY priority ASC, created_at ASC
|
|
1475
|
-
LIMIT 1
|
|
1476
|
-
),
|
|
1477
|
-
claimed AS (
|
|
1478
|
-
UPDATE ${tbl}
|
|
1479
|
-
SET status = 'claimed',
|
|
1480
|
-
claimed_at = NOW(),
|
|
1481
|
-
claim_expires_at = NOW() + INTERVAL '${minutes} minutes',
|
|
1482
|
-
assigned_to = COALESCE($3, assigned_to),
|
|
1483
|
-
updated_at = NOW()
|
|
1484
|
-
WHERE namespace = $1 AND app_id = $2
|
|
1485
|
-
AND id = (SELECT id FROM found)
|
|
1486
|
-
AND status = 'pending'
|
|
1487
|
-
RETURNING *
|
|
1488
|
-
)
|
|
1489
|
-
SELECT c.*, f.id AS f_id
|
|
1490
|
-
FROM found f
|
|
1491
|
-
LEFT JOIN claimed c ON c.id = f.id`, [params.namespace, params.appId, params.assignee ?? null, containsJson]);
|
|
1492
|
-
if (result.rows.length === 0 || !result.rows[0].f_id) {
|
|
1493
|
-
return { ok: false, reason: 'not-found' };
|
|
1494
|
-
}
|
|
1495
|
-
if (!result.rows[0].id) {
|
|
1496
|
-
return { ok: false, reason: 'conflict' };
|
|
1497
|
-
}
|
|
1498
|
-
return { ok: true, entry: this.rowToSignalEntry(result.rows[0]) };
|
|
1499
|
-
}
|
|
1500
|
-
async releaseSignal(params) {
|
|
1501
|
-
const tbl = this.signalQueueTable();
|
|
1502
|
-
const result = await this.pgClient.query(`WITH found AS (
|
|
1503
|
-
SELECT id, status FROM ${tbl}
|
|
1504
|
-
WHERE namespace = $1 AND app_id = $2 AND id = $3
|
|
1505
|
-
),
|
|
1506
|
-
released AS (
|
|
1507
|
-
UPDATE ${tbl}
|
|
1508
|
-
SET status = 'pending',
|
|
1509
|
-
claimed_at = NULL,
|
|
1510
|
-
claim_expires_at = NULL,
|
|
1511
|
-
assigned_to = NULL,
|
|
1512
|
-
updated_at = NOW()
|
|
1513
|
-
WHERE namespace = $1 AND app_id = $2
|
|
1514
|
-
AND id = $3
|
|
1515
|
-
AND status = 'claimed'
|
|
1516
|
-
RETURNING id
|
|
1517
|
-
)
|
|
1518
|
-
SELECT r.id AS r_id, f.id AS f_id
|
|
1519
|
-
FROM found f
|
|
1520
|
-
LEFT JOIN released r ON r.id = f.id`, [params.namespace, params.appId, params.id]);
|
|
1521
|
-
if (result.rows.length === 0 || !result.rows[0].f_id) {
|
|
1522
|
-
return { ok: false, reason: 'not-found' };
|
|
1523
|
-
}
|
|
1524
|
-
if (!result.rows[0].r_id) {
|
|
1525
|
-
return { ok: false, reason: 'wrong-status' };
|
|
1526
|
-
}
|
|
1527
|
-
return { ok: true };
|
|
1528
|
-
}
|
|
1529
|
-
async resolveSignal(params) {
|
|
1530
|
-
const tbl = this.signalQueueTable();
|
|
1531
|
-
const result = await this.pgClient.query(`WITH found AS (
|
|
1532
|
-
SELECT id, status, signal_key, topic FROM ${tbl}
|
|
1533
|
-
WHERE namespace = $1 AND app_id = $2 AND id = $4
|
|
1534
|
-
),
|
|
1535
|
-
resolved AS (
|
|
1536
|
-
UPDATE ${tbl}
|
|
1537
|
-
SET status = 'resolved',
|
|
1538
|
-
resolved_at = NOW(),
|
|
1539
|
-
resolver_payload = $3::jsonb,
|
|
1540
|
-
updated_at = NOW()
|
|
1541
|
-
WHERE namespace = $1 AND app_id = $2
|
|
1542
|
-
AND id = $4
|
|
1543
|
-
AND status IN ('pending', 'claimed')
|
|
1544
|
-
RETURNING id, signal_key, topic
|
|
1545
|
-
)
|
|
1546
|
-
SELECT r.id AS r_id, r.signal_key, r.topic, f.id AS f_id
|
|
1547
|
-
FROM found f
|
|
1548
|
-
LEFT JOIN resolved r ON r.id = f.id`, [
|
|
1549
|
-
params.namespace,
|
|
1550
|
-
params.appId,
|
|
1551
|
-
params.resolverPayload ? JSON.stringify(params.resolverPayload) : null,
|
|
1552
|
-
params.id,
|
|
1553
|
-
]);
|
|
1554
|
-
if (result.rows.length === 0 || !result.rows[0].f_id) {
|
|
1555
|
-
return { ok: false, reason: 'not-found' };
|
|
1556
|
-
}
|
|
1557
|
-
if (!result.rows[0].r_id) {
|
|
1558
|
-
return { ok: false, reason: 'already-resolved' };
|
|
1559
|
-
}
|
|
1560
|
-
return {
|
|
1561
|
-
ok: true,
|
|
1562
|
-
signalKey: result.rows[0].signal_key,
|
|
1563
|
-
topic: result.rows[0].topic,
|
|
1564
|
-
};
|
|
1565
|
-
}
|
|
1566
|
-
async resolveSignalByMetadata(params) {
|
|
1567
|
-
const tbl = this.signalQueueTable();
|
|
1568
|
-
const containsJson = JSON.stringify({ [params.key]: params.value });
|
|
1569
|
-
const result = await this.pgClient.query(`WITH found AS (
|
|
1570
|
-
SELECT id, signal_key, topic FROM ${tbl}
|
|
1571
|
-
WHERE namespace = $1 AND app_id = $2
|
|
1572
|
-
AND status IN ('pending', 'claimed')
|
|
1573
|
-
AND metadata @> $4::jsonb
|
|
1574
|
-
LIMIT 1
|
|
1575
|
-
),
|
|
1576
|
-
resolved AS (
|
|
1577
|
-
UPDATE ${tbl}
|
|
1578
|
-
SET status = 'resolved',
|
|
1579
|
-
resolved_at = NOW(),
|
|
1580
|
-
resolver_payload = $3::jsonb,
|
|
1581
|
-
updated_at = NOW()
|
|
1582
|
-
WHERE namespace = $1 AND app_id = $2
|
|
1583
|
-
AND id = (SELECT id FROM found)
|
|
1584
|
-
AND status IN ('pending', 'claimed')
|
|
1585
|
-
RETURNING id, signal_key, topic
|
|
1586
|
-
)
|
|
1587
|
-
SELECT r.id AS r_id, r.signal_key, r.topic, f.id AS f_id
|
|
1588
|
-
FROM found f
|
|
1589
|
-
LEFT JOIN resolved r ON r.id = f.id`, [
|
|
1590
|
-
params.namespace,
|
|
1591
|
-
params.appId,
|
|
1592
|
-
params.resolverPayload ? JSON.stringify(params.resolverPayload) : null,
|
|
1593
|
-
containsJson,
|
|
1594
|
-
]);
|
|
1595
|
-
if (result.rows.length === 0 || !result.rows[0].f_id) {
|
|
1596
|
-
return { ok: false, reason: 'not-found' };
|
|
1597
|
-
}
|
|
1598
|
-
return {
|
|
1599
|
-
ok: true,
|
|
1600
|
-
signalKey: result.rows[0].signal_key,
|
|
1601
|
-
topic: result.rows[0].topic,
|
|
1602
|
-
};
|
|
1603
|
-
}
|
|
1604
|
-
async releaseExpiredSignals(params) {
|
|
1605
|
-
const tbl = this.signalQueueTable();
|
|
1606
|
-
const result = await this.pgClient.query(`UPDATE ${tbl}
|
|
1607
|
-
SET status = 'pending',
|
|
1608
|
-
claimed_at = NULL,
|
|
1609
|
-
claim_expires_at = NULL,
|
|
1610
|
-
updated_at = NOW()
|
|
1611
|
-
WHERE namespace = $1 AND app_id = $2
|
|
1612
|
-
AND status = 'claimed'
|
|
1613
|
-
AND claim_expires_at < NOW()`, [params.namespace, params.appId]);
|
|
1614
|
-
return result.rowCount ?? 0;
|
|
1615
|
-
}
|
|
1616
|
-
async listSignals(params) {
|
|
1617
|
-
const tbl = this.signalQueueTable();
|
|
1618
|
-
const conditions = ['namespace = $1', 'app_id = $2'];
|
|
1619
|
-
const values = [params.namespace, params.appId];
|
|
1620
|
-
let idx = 3;
|
|
1621
|
-
if (params.status) {
|
|
1622
|
-
conditions.push(`status = $${idx++}`);
|
|
1623
|
-
values.push(params.status);
|
|
1624
|
-
}
|
|
1625
|
-
if (params.role) {
|
|
1626
|
-
conditions.push(`role = $${idx++}`);
|
|
1627
|
-
values.push(params.role);
|
|
1628
|
-
}
|
|
1629
|
-
if (params.taskQueue) {
|
|
1630
|
-
conditions.push(`task_queue = $${idx++}`);
|
|
1631
|
-
values.push(params.taskQueue);
|
|
1632
|
-
}
|
|
1633
|
-
const where = conditions.join(' AND ');
|
|
1634
|
-
const limit = params.limit ?? 50;
|
|
1635
|
-
const offset = params.offset ?? 0;
|
|
1636
|
-
const result = await this.pgClient.query(`SELECT * FROM ${tbl} WHERE ${where} ORDER BY priority ASC, created_at ASC LIMIT $${idx++} OFFSET $${idx}`, [...values, limit, offset]);
|
|
1637
|
-
return result.rows.map(r => this.rowToSignalEntry(r));
|
|
1638
|
-
}
|
|
1639
|
-
async getSignal(params) {
|
|
1640
|
-
const tbl = this.signalQueueTable();
|
|
1641
|
-
const result = await this.pgClient.query(`SELECT * FROM ${tbl} WHERE namespace = $1 AND app_id = $2 AND id = $3`, [params.namespace, params.appId, params.id]);
|
|
1642
|
-
if (result.rows.length === 0)
|
|
1643
|
-
return null;
|
|
1644
|
-
return this.rowToSignalEntry(result.rows[0]);
|
|
1645
|
-
}
|
|
1646
|
-
async getSignalBySignalKey(params) {
|
|
1647
|
-
const tbl = this.signalQueueTable();
|
|
1648
|
-
const result = await this.pgClient.query(`SELECT * FROM ${tbl} WHERE namespace = $1 AND app_id = $2 AND signal_key = $3 LIMIT 1`, [params.namespace, params.appId, params.signalKey]);
|
|
1649
|
-
if (result.rows.length === 0)
|
|
1650
|
-
return null;
|
|
1651
|
-
return this.rowToSignalEntry(result.rows[0]);
|
|
1652
|
-
}
|
|
1653
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
1654
1384
|
/**
|
|
1655
1385
|
* Parse a HotMesh-encoded value string.
|
|
1656
1386
|
* Values may be prefixed with `/s` (JSON), `/d` (number), `/t` or `/f` (boolean), `/n` (null).
|
|
@@ -1669,5 +1399,532 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1669
1399
|
default: return raw;
|
|
1670
1400
|
}
|
|
1671
1401
|
}
|
|
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;
|
|
1404
|
+
return [
|
|
1405
|
+
namespace ?? 'hmsh',
|
|
1406
|
+
appId ?? 'hmsh',
|
|
1407
|
+
signalKey ?? null,
|
|
1408
|
+
topic ?? null,
|
|
1409
|
+
workflowId ?? null,
|
|
1410
|
+
taskQueue ?? null,
|
|
1411
|
+
workflowType ?? null,
|
|
1412
|
+
type ?? null,
|
|
1413
|
+
subtype ?? null,
|
|
1414
|
+
entity ?? null,
|
|
1415
|
+
description ?? null,
|
|
1416
|
+
role ?? null,
|
|
1417
|
+
priority ?? 5,
|
|
1418
|
+
originId ?? null,
|
|
1419
|
+
parentId ?? null,
|
|
1420
|
+
initiatedBy ?? null,
|
|
1421
|
+
createdBy ?? null,
|
|
1422
|
+
traceId ?? null,
|
|
1423
|
+
spanId ?? null,
|
|
1424
|
+
escalationPayload ? JSON.stringify(escalationPayload) : null,
|
|
1425
|
+
metadata ? JSON.stringify(metadata) : null,
|
|
1426
|
+
envelope ? JSON.stringify(envelope) : null,
|
|
1427
|
+
expiresAt ?? null,
|
|
1428
|
+
];
|
|
1429
|
+
}
|
|
1430
|
+
async createEscalation(params) {
|
|
1431
|
+
const result = await this.pgClient.query(this._escalationInsertSql + ' RETURNING *', this._escalationInsertParams(params));
|
|
1432
|
+
return result.rows[0];
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Enqueues the escalation INSERT into an existing Leg1 transaction so the row
|
|
1436
|
+
* is written atomically with the job state checkpoint. On conflict
|
|
1437
|
+
* (ON CONFLICT DO NOTHING) the command is a no-op, making it safe for
|
|
1438
|
+
* idempotent re-runs after a crash.
|
|
1439
|
+
*/
|
|
1440
|
+
addEscalationToTransaction(params, transaction) {
|
|
1441
|
+
transaction.addCommand(this._escalationInsertSql, this._escalationInsertParams(params), 'void');
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Full-fidelity INSERT for data migration. Preserves the original `id` (UUID),
|
|
1445
|
+
* lifecycle state, and timestamps from the source table. Uses
|
|
1446
|
+
* `ON CONFLICT (id) DO NOTHING` so re-running a migration batch is safe —
|
|
1447
|
+
* rows that already exist are skipped and `null` is returned for them.
|
|
1448
|
+
*/
|
|
1449
|
+
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 result = await this.pgClient.query(`
|
|
1452
|
+
INSERT INTO public.hmsh_escalations
|
|
1453
|
+
(id, namespace, app_id, signal_key, topic, workflow_id, task_queue, workflow_type,
|
|
1454
|
+
type, subtype, entity, description, role, priority,
|
|
1455
|
+
origin_id, parent_id, initiated_by, created_by, trace_id, span_id,
|
|
1456
|
+
escalation_payload, metadata, envelope, expires_at,
|
|
1457
|
+
status, assigned_to, claim_expires_at, claimed_at, resolved_at,
|
|
1458
|
+
resolver_payload, milestones, created_at, updated_at)
|
|
1459
|
+
VALUES
|
|
1460
|
+
($1, $2, $3, $4, $5, $6, $7, $8,
|
|
1461
|
+
$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)
|
|
1466
|
+
ON CONFLICT (id) DO NOTHING
|
|
1467
|
+
RETURNING *
|
|
1468
|
+
`, [
|
|
1469
|
+
id,
|
|
1470
|
+
namespace ?? 'hmsh',
|
|
1471
|
+
appId ?? 'hmsh',
|
|
1472
|
+
signalKey ?? null,
|
|
1473
|
+
topic ?? null,
|
|
1474
|
+
workflowId ?? null,
|
|
1475
|
+
taskQueue ?? null,
|
|
1476
|
+
workflowType ?? null,
|
|
1477
|
+
type ?? null,
|
|
1478
|
+
subtype ?? null,
|
|
1479
|
+
entity ?? null,
|
|
1480
|
+
description ?? null,
|
|
1481
|
+
role ?? null,
|
|
1482
|
+
priority ?? 5,
|
|
1483
|
+
originId ?? null,
|
|
1484
|
+
parentId ?? null,
|
|
1485
|
+
initiatedBy ?? null,
|
|
1486
|
+
createdBy ?? null,
|
|
1487
|
+
traceId ?? null,
|
|
1488
|
+
spanId ?? null,
|
|
1489
|
+
escalationPayload ? JSON.stringify(escalationPayload) : null,
|
|
1490
|
+
metadata ? JSON.stringify(metadata) : null,
|
|
1491
|
+
envelope ? JSON.stringify(envelope) : null,
|
|
1492
|
+
expiresAt ?? null,
|
|
1493
|
+
status ?? 'pending',
|
|
1494
|
+
assignedTo ?? null,
|
|
1495
|
+
claimExpiresAt ?? null,
|
|
1496
|
+
claimedAt ?? null,
|
|
1497
|
+
resolvedAt ?? null,
|
|
1498
|
+
resolverPayload ? JSON.stringify(resolverPayload) : null,
|
|
1499
|
+
milestones ? JSON.stringify(milestones) : '[]',
|
|
1500
|
+
createdAt ?? new Date(),
|
|
1501
|
+
updatedAt ?? new Date(),
|
|
1502
|
+
]);
|
|
1503
|
+
return result.rows[0] ?? null;
|
|
1504
|
+
}
|
|
1505
|
+
async getEscalation(id, namespace) {
|
|
1506
|
+
const result = await this.pgClient.query(`SELECT * FROM public.hmsh_escalations WHERE id = $1${namespace ? ' AND namespace = $2' : ''}`, namespace ? [id, namespace] : [id]);
|
|
1507
|
+
return result.rows[0] ?? null;
|
|
1508
|
+
}
|
|
1509
|
+
async getEscalationBySignalKey(signalKey, namespace) {
|
|
1510
|
+
const result = await this.pgClient.query(`SELECT * FROM public.hmsh_escalations WHERE signal_key = $1${namespace ? ' AND namespace = $2' : ''}`, namespace ? [signalKey, namespace] : [signalKey]);
|
|
1511
|
+
return result.rows[0] ?? null;
|
|
1512
|
+
}
|
|
1513
|
+
async listEscalations(params = {}) {
|
|
1514
|
+
const { namespace, role, type, subtype, entity, status, assignedTo, workflowId, originId, limit = 50, offset = 0 } = params;
|
|
1515
|
+
const conditions = [];
|
|
1516
|
+
const values = [];
|
|
1517
|
+
let idx = 1;
|
|
1518
|
+
if (namespace) {
|
|
1519
|
+
conditions.push(`namespace = $${idx++}`);
|
|
1520
|
+
values.push(namespace);
|
|
1521
|
+
}
|
|
1522
|
+
if (role) {
|
|
1523
|
+
conditions.push(`role = $${idx++}`);
|
|
1524
|
+
values.push(role);
|
|
1525
|
+
}
|
|
1526
|
+
if (type) {
|
|
1527
|
+
conditions.push(`type = $${idx++}`);
|
|
1528
|
+
values.push(type);
|
|
1529
|
+
}
|
|
1530
|
+
if (subtype) {
|
|
1531
|
+
conditions.push(`subtype = $${idx++}`);
|
|
1532
|
+
values.push(subtype);
|
|
1533
|
+
}
|
|
1534
|
+
if (entity) {
|
|
1535
|
+
conditions.push(`entity = $${idx++}`);
|
|
1536
|
+
values.push(entity);
|
|
1537
|
+
}
|
|
1538
|
+
if (status) {
|
|
1539
|
+
conditions.push(`status = $${idx++}`);
|
|
1540
|
+
values.push(status);
|
|
1541
|
+
}
|
|
1542
|
+
if (assignedTo) {
|
|
1543
|
+
conditions.push(`assigned_to = $${idx++}`);
|
|
1544
|
+
values.push(assignedTo);
|
|
1545
|
+
}
|
|
1546
|
+
if (workflowId) {
|
|
1547
|
+
conditions.push(`workflow_id = $${idx++}`);
|
|
1548
|
+
values.push(workflowId);
|
|
1549
|
+
}
|
|
1550
|
+
if (originId) {
|
|
1551
|
+
conditions.push(`origin_id = $${idx++}`);
|
|
1552
|
+
values.push(originId);
|
|
1553
|
+
}
|
|
1554
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
1555
|
+
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);
|
|
1557
|
+
return result.rows;
|
|
1558
|
+
}
|
|
1559
|
+
async claimEscalation(params) {
|
|
1560
|
+
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.
|
|
1565
|
+
const result = await this.pgClient.query(`
|
|
1566
|
+
WITH all_rows AS MATERIALIZED (
|
|
1567
|
+
SELECT id FROM public.hmsh_escalations
|
|
1568
|
+
WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''}
|
|
1569
|
+
),
|
|
1570
|
+
target AS MATERIALIZED (
|
|
1571
|
+
SELECT id FROM public.hmsh_escalations
|
|
1572
|
+
WHERE id = $1
|
|
1573
|
+
${namespace ? 'AND namespace = $4' : ''}
|
|
1574
|
+
AND status = 'pending'
|
|
1575
|
+
AND (assigned_to IS NULL OR claim_expires_at <= NOW() OR assigned_to = $2)
|
|
1576
|
+
LIMIT 1
|
|
1577
|
+
FOR UPDATE SKIP LOCKED
|
|
1578
|
+
),
|
|
1579
|
+
claimed AS (
|
|
1580
|
+
UPDATE public.hmsh_escalations
|
|
1581
|
+
SET status = 'claimed',
|
|
1582
|
+
assigned_to = $2,
|
|
1583
|
+
claimed_at = NOW(),
|
|
1584
|
+
claim_expires_at = NOW() + ($3 * INTERVAL '1 minute'),
|
|
1585
|
+
updated_at = NOW()
|
|
1586
|
+
FROM target WHERE public.hmsh_escalations.id = target.id
|
|
1587
|
+
RETURNING public.hmsh_escalations.*
|
|
1588
|
+
)
|
|
1589
|
+
SELECT c.*, (SELECT EXISTS(SELECT 1 FROM all_rows)) AS row_exists
|
|
1590
|
+
FROM (VALUES(1)) AS dummy(v)
|
|
1591
|
+
LEFT JOIN claimed c ON true
|
|
1592
|
+
`, namespace ? [id, assignee, durationMinutes, namespace] : [id, assignee, durationMinutes]);
|
|
1593
|
+
const row = result.rows[0];
|
|
1594
|
+
if (row?.id)
|
|
1595
|
+
return { ok: true, entry: row };
|
|
1596
|
+
if (row?.row_exists)
|
|
1597
|
+
return { ok: false, reason: 'conflict' };
|
|
1598
|
+
return { ok: false, reason: 'not-found' };
|
|
1599
|
+
}
|
|
1600
|
+
async claimEscalationByMetadata(params) {
|
|
1601
|
+
const { key, value, namespace, assignee = '', durationMinutes = 1, roles } = params;
|
|
1602
|
+
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.
|
|
1607
|
+
const result = await this.pgClient.query(`
|
|
1608
|
+
WITH all_candidates AS MATERIALIZED (
|
|
1609
|
+
SELECT id FROM public.hmsh_escalations
|
|
1610
|
+
WHERE ${namespace ? 'namespace = $5 AND' : ''}
|
|
1611
|
+
metadata @> $1::jsonb
|
|
1612
|
+
AND ($4::text[] IS NULL OR role = ANY($4::text[]))
|
|
1613
|
+
),
|
|
1614
|
+
target AS MATERIALIZED (
|
|
1615
|
+
SELECT id FROM public.hmsh_escalations
|
|
1616
|
+
WHERE ${namespace ? 'namespace = $5 AND' : ''}
|
|
1617
|
+
metadata @> $1::jsonb
|
|
1618
|
+
AND ($4::text[] IS NULL OR role = ANY($4::text[]))
|
|
1619
|
+
AND status = 'pending'
|
|
1620
|
+
AND (assigned_to IS NULL OR claim_expires_at <= NOW() OR assigned_to = $2)
|
|
1621
|
+
ORDER BY priority ASC, created_at ASC
|
|
1622
|
+
LIMIT 1
|
|
1623
|
+
FOR UPDATE SKIP LOCKED
|
|
1624
|
+
),
|
|
1625
|
+
claimed AS (
|
|
1626
|
+
UPDATE public.hmsh_escalations
|
|
1627
|
+
SET status = 'claimed',
|
|
1628
|
+
assigned_to = $2,
|
|
1629
|
+
claimed_at = NOW(),
|
|
1630
|
+
claim_expires_at = NOW() + ($3 * INTERVAL '1 minute'),
|
|
1631
|
+
updated_at = NOW()
|
|
1632
|
+
FROM target WHERE public.hmsh_escalations.id = target.id
|
|
1633
|
+
RETURNING public.hmsh_escalations.*
|
|
1634
|
+
)
|
|
1635
|
+
SELECT c.*, (SELECT COUNT(*)::int FROM all_candidates) AS candidates_exist
|
|
1636
|
+
FROM (VALUES(1)) AS dummy(v)
|
|
1637
|
+
LEFT JOIN claimed c ON true
|
|
1638
|
+
`, namespace
|
|
1639
|
+
? [filter, assignee, durationMinutes, roles ?? null, namespace]
|
|
1640
|
+
: [filter, assignee, durationMinutes, roles ?? null]);
|
|
1641
|
+
const row = result.rows[0];
|
|
1642
|
+
const candidatesExist = row?.candidates_exist ?? 0;
|
|
1643
|
+
if (row?.id)
|
|
1644
|
+
return { ok: true, entry: row, candidatesExist };
|
|
1645
|
+
return {
|
|
1646
|
+
ok: false,
|
|
1647
|
+
reason: candidatesExist > 0 ? 'conflict' : 'not-found',
|
|
1648
|
+
candidatesExist,
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
async releaseEscalation(params) {
|
|
1652
|
+
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
|
+
const result = await this.pgClient.query(`
|
|
1657
|
+
WITH target AS MATERIALIZED (
|
|
1658
|
+
SELECT id, assigned_to FROM public.hmsh_escalations
|
|
1659
|
+
WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
|
|
1660
|
+
AND status = 'claimed'
|
|
1661
|
+
FOR UPDATE
|
|
1662
|
+
),
|
|
1663
|
+
released AS (
|
|
1664
|
+
UPDATE public.hmsh_escalations
|
|
1665
|
+
SET status = 'pending', assigned_to = NULL, assigned_until = NULL,
|
|
1666
|
+
claimed_at = NULL, claim_expires_at = NULL, updated_at = NOW()
|
|
1667
|
+
FROM target
|
|
1668
|
+
WHERE public.hmsh_escalations.id = target.id
|
|
1669
|
+
AND ($2::text IS NULL OR target.assigned_to = $2)
|
|
1670
|
+
RETURNING public.hmsh_escalations.id
|
|
1671
|
+
)
|
|
1672
|
+
SELECT t.id AS target_id, t.assigned_to AS current_assignee, r.id AS released_id
|
|
1673
|
+
FROM target t
|
|
1674
|
+
LEFT JOIN released r ON r.id = t.id
|
|
1675
|
+
`, namespace ? [id, assignee ?? null, namespace] : [id, assignee ?? null]);
|
|
1676
|
+
const row = result.rows[0];
|
|
1677
|
+
if (!row)
|
|
1678
|
+
return { ok: false, reason: 'not-found' };
|
|
1679
|
+
if (!row.released_id)
|
|
1680
|
+
return { ok: false, reason: 'wrong-assignee' };
|
|
1681
|
+
return { ok: true };
|
|
1682
|
+
}
|
|
1683
|
+
async resolveEscalation(params) {
|
|
1684
|
+
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 };
|
|
1721
|
+
}
|
|
1722
|
+
async resolveEscalationByMetadata(params) {
|
|
1723
|
+
const { key, value, namespace, resolverPayload, roles } = params;
|
|
1724
|
+
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
|
|
1776
|
+
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;
|
|
1799
|
+
}
|
|
1800
|
+
async cancelEscalation(id, namespace) {
|
|
1801
|
+
const result = await this.pgClient.query(`
|
|
1802
|
+
WITH target AS MATERIALIZED (
|
|
1803
|
+
SELECT id, status FROM public.hmsh_escalations
|
|
1804
|
+
WHERE id = $1 ${namespace ? 'AND namespace = $2' : ''}
|
|
1805
|
+
LIMIT 1 FOR UPDATE
|
|
1806
|
+
),
|
|
1807
|
+
cancelled AS (
|
|
1808
|
+
UPDATE public.hmsh_escalations
|
|
1809
|
+
SET status = 'cancelled', updated_at = NOW()
|
|
1810
|
+
FROM target
|
|
1811
|
+
WHERE public.hmsh_escalations.id = target.id
|
|
1812
|
+
AND target.status IN ('pending', 'claimed')
|
|
1813
|
+
RETURNING public.hmsh_escalations.id
|
|
1814
|
+
)
|
|
1815
|
+
SELECT t.id, t.status AS prior_status,
|
|
1816
|
+
CASE
|
|
1817
|
+
WHEN c.id IS NOT NULL THEN 'cancelled'
|
|
1818
|
+
WHEN t.id IS NULL THEN 'not-found'
|
|
1819
|
+
ELSE 'already-terminal'
|
|
1820
|
+
END AS outcome
|
|
1821
|
+
FROM (SELECT * FROM target) t
|
|
1822
|
+
FULL OUTER JOIN (SELECT id FROM cancelled) c ON c.id = t.id
|
|
1823
|
+
`, namespace ? [id, namespace] : [id]);
|
|
1824
|
+
if (!result.rows[0] || result.rows[0].outcome === 'not-found')
|
|
1825
|
+
return { ok: false, reason: 'not-found' };
|
|
1826
|
+
if (result.rows[0].outcome === 'already-terminal')
|
|
1827
|
+
return { ok: false, reason: 'already-terminal' };
|
|
1828
|
+
return { ok: true };
|
|
1829
|
+
}
|
|
1830
|
+
async escalateEscalationToRole(params) {
|
|
1831
|
+
const { id, targetRole, namespace } = params;
|
|
1832
|
+
const result = await this.pgClient.query(`
|
|
1833
|
+
UPDATE public.hmsh_escalations
|
|
1834
|
+
SET role = $2,
|
|
1835
|
+
status = 'pending',
|
|
1836
|
+
assigned_to = NULL,
|
|
1837
|
+
assigned_until = NULL,
|
|
1838
|
+
claimed_at = NULL,
|
|
1839
|
+
claim_expires_at = NULL,
|
|
1840
|
+
updated_at = NOW()
|
|
1841
|
+
WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
|
|
1842
|
+
AND status IN ('pending', 'claimed')
|
|
1843
|
+
RETURNING *
|
|
1844
|
+
`, namespace ? [id, targetRole, namespace] : [id, targetRole]);
|
|
1845
|
+
return result.rows[0] ?? null;
|
|
1846
|
+
}
|
|
1847
|
+
async updateEscalation(params) {
|
|
1848
|
+
const { id, namespace, description, priority, role, metadata, envelope, signalKey, topic, workflowId, taskQueue, workflowType, expiresAt } = params;
|
|
1849
|
+
const sets = [];
|
|
1850
|
+
const values = [id];
|
|
1851
|
+
let idx = 2;
|
|
1852
|
+
if (description !== undefined) {
|
|
1853
|
+
sets.push(`description = $${idx++}`);
|
|
1854
|
+
values.push(description);
|
|
1855
|
+
}
|
|
1856
|
+
if (priority !== undefined) {
|
|
1857
|
+
sets.push(`priority = $${idx++}`);
|
|
1858
|
+
values.push(priority);
|
|
1859
|
+
}
|
|
1860
|
+
if (role !== undefined) {
|
|
1861
|
+
sets.push(`role = $${idx++}`);
|
|
1862
|
+
values.push(role);
|
|
1863
|
+
}
|
|
1864
|
+
if (envelope !== undefined) {
|
|
1865
|
+
sets.push(`envelope = $${idx++}`);
|
|
1866
|
+
values.push(JSON.stringify(envelope));
|
|
1867
|
+
}
|
|
1868
|
+
if (signalKey !== undefined) {
|
|
1869
|
+
sets.push(`signal_key = $${idx++}`);
|
|
1870
|
+
values.push(signalKey);
|
|
1871
|
+
}
|
|
1872
|
+
if (topic !== undefined) {
|
|
1873
|
+
sets.push(`topic = $${idx++}`);
|
|
1874
|
+
values.push(topic);
|
|
1875
|
+
}
|
|
1876
|
+
if (workflowId !== undefined) {
|
|
1877
|
+
sets.push(`workflow_id = $${idx++}`);
|
|
1878
|
+
values.push(workflowId);
|
|
1879
|
+
}
|
|
1880
|
+
if (taskQueue !== undefined) {
|
|
1881
|
+
sets.push(`task_queue = $${idx++}`);
|
|
1882
|
+
values.push(taskQueue);
|
|
1883
|
+
}
|
|
1884
|
+
if (workflowType !== undefined) {
|
|
1885
|
+
sets.push(`workflow_type = $${idx++}`);
|
|
1886
|
+
values.push(workflowType);
|
|
1887
|
+
}
|
|
1888
|
+
if (expiresAt !== undefined) {
|
|
1889
|
+
sets.push(`expires_at = $${idx++}`);
|
|
1890
|
+
values.push(expiresAt);
|
|
1891
|
+
}
|
|
1892
|
+
// Metadata is merged (not replaced) — caller patches individual keys
|
|
1893
|
+
if (metadata !== undefined) {
|
|
1894
|
+
sets.push(`metadata = COALESCE(metadata, '{}'::jsonb) || $${idx++}::jsonb`);
|
|
1895
|
+
values.push(JSON.stringify(metadata));
|
|
1896
|
+
}
|
|
1897
|
+
if (!sets.length)
|
|
1898
|
+
return this.getEscalation(id, namespace);
|
|
1899
|
+
sets.push(`updated_at = NOW()`);
|
|
1900
|
+
const nsClause = namespace ? ` AND namespace = $${idx++}` : '';
|
|
1901
|
+
if (namespace)
|
|
1902
|
+
values.push(namespace);
|
|
1903
|
+
const result = await this.pgClient.query(`UPDATE public.hmsh_escalations SET ${sets.join(', ')} WHERE id = $1${nsClause} RETURNING *`, values);
|
|
1904
|
+
return result.rows[0] ?? null;
|
|
1905
|
+
}
|
|
1906
|
+
async appendEscalationMilestones(params) {
|
|
1907
|
+
const { id, namespace, milestones } = params;
|
|
1908
|
+
const stamped = milestones.map(m => ({ ...m, created_at: new Date().toISOString() }));
|
|
1909
|
+
const result = await this.pgClient.query(`
|
|
1910
|
+
UPDATE public.hmsh_escalations
|
|
1911
|
+
SET milestones = milestones || $2::jsonb,
|
|
1912
|
+
updated_at = NOW()
|
|
1913
|
+
WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
|
|
1914
|
+
RETURNING *
|
|
1915
|
+
`, namespace ? [id, JSON.stringify(stamped), namespace] : [id, JSON.stringify(stamped)]);
|
|
1916
|
+
return result.rows[0] ?? null;
|
|
1917
|
+
}
|
|
1918
|
+
async releaseExpiredEscalations(namespace) {
|
|
1919
|
+
const result = await this.pgClient.query(`
|
|
1920
|
+
UPDATE public.hmsh_escalations
|
|
1921
|
+
SET status = 'pending', assigned_to = NULL, assigned_until = NULL,
|
|
1922
|
+
claimed_at = NULL, claim_expires_at = NULL, updated_at = NOW()
|
|
1923
|
+
WHERE status = 'claimed'
|
|
1924
|
+
AND claim_expires_at <= NOW()
|
|
1925
|
+
${namespace ? 'AND namespace = $1' : ''}
|
|
1926
|
+
`, namespace ? [namespace] : []);
|
|
1927
|
+
return result.rowCount ?? 0;
|
|
1928
|
+
}
|
|
1672
1929
|
}
|
|
1673
1930
|
exports.PostgresStoreService = PostgresStoreService;
|