@hotmeshio/hotmesh 0.21.0 → 0.22.0

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.
Files changed (30) hide show
  1. package/README.md +12 -129
  2. package/build/modules/utils.js +3 -0
  3. package/build/package.json +2 -1
  4. package/build/services/activities/hook.d.ts +178 -58
  5. package/build/services/activities/hook.js +244 -58
  6. package/build/services/activities/trigger.js +5 -1
  7. package/build/services/durable/client.d.ts +238 -66
  8. package/build/services/durable/client.js +309 -125
  9. package/build/services/durable/index.d.ts +0 -2
  10. package/build/services/durable/schemas/factory.js +40 -0
  11. package/build/services/durable/worker.js +5 -28
  12. package/build/services/durable/workflow/condition.d.ts +69 -37
  13. package/build/services/durable/workflow/condition.js +70 -39
  14. package/build/services/hotmesh/index.d.ts +31 -4
  15. package/build/services/hotmesh/index.js +31 -4
  16. package/build/services/store/index.d.ts +1 -1
  17. package/build/services/store/providers/postgres/kvsql.d.ts +1 -1
  18. package/build/services/store/providers/postgres/kvtables.js +83 -122
  19. package/build/services/store/providers/postgres/kvtypes/hash/basic.d.ts +1 -1
  20. package/build/services/store/providers/postgres/kvtypes/hash/basic.js +8 -8
  21. package/build/services/store/providers/postgres/kvtypes/hash/index.d.ts +1 -1
  22. package/build/services/store/providers/postgres/postgres.d.ts +44 -157
  23. package/build/services/store/providers/postgres/postgres.js +480 -278
  24. package/build/types/activity.d.ts +2 -0
  25. package/build/types/hmsh_escalations.d.ts +212 -0
  26. package/build/types/index.d.ts +1 -1
  27. package/build/types/provider.d.ts +2 -0
  28. package/package.json +2 -1
  29. package/build/types/signal.d.ts +0 -147
  30. /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,282 +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
- // ─────────────────────────────────────────────────────────────────────────
1647
1384
  /**
1648
1385
  * Parse a HotMesh-encoded value string.
1649
1386
  * Values may be prefixed with `/s` (JSON), `/d` (number), `/t` or `/f` (boolean), `/n` (null).
@@ -1662,5 +1399,470 @@ class PostgresStoreService extends __1.StoreService {
1662
1399
  default: return raw;
1663
1400
  }
1664
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
+ async getEscalation(id, namespace) {
1444
+ const result = await this.pgClient.query(`SELECT * FROM public.hmsh_escalations WHERE id = $1${namespace ? ' AND namespace = $2' : ''}`, namespace ? [id, namespace] : [id]);
1445
+ return result.rows[0] ?? null;
1446
+ }
1447
+ async getEscalationBySignalKey(signalKey, namespace) {
1448
+ const result = await this.pgClient.query(`SELECT * FROM public.hmsh_escalations WHERE signal_key = $1${namespace ? ' AND namespace = $2' : ''}`, namespace ? [signalKey, namespace] : [signalKey]);
1449
+ return result.rows[0] ?? null;
1450
+ }
1451
+ async listEscalations(params = {}) {
1452
+ const { namespace, role, type, subtype, entity, status, assignedTo, workflowId, originId, limit = 50, offset = 0 } = params;
1453
+ const conditions = [];
1454
+ const values = [];
1455
+ let idx = 1;
1456
+ if (namespace) {
1457
+ conditions.push(`namespace = $${idx++}`);
1458
+ values.push(namespace);
1459
+ }
1460
+ if (role) {
1461
+ conditions.push(`role = $${idx++}`);
1462
+ values.push(role);
1463
+ }
1464
+ if (type) {
1465
+ conditions.push(`type = $${idx++}`);
1466
+ values.push(type);
1467
+ }
1468
+ if (subtype) {
1469
+ conditions.push(`subtype = $${idx++}`);
1470
+ values.push(subtype);
1471
+ }
1472
+ if (entity) {
1473
+ conditions.push(`entity = $${idx++}`);
1474
+ values.push(entity);
1475
+ }
1476
+ if (status) {
1477
+ conditions.push(`status = $${idx++}`);
1478
+ values.push(status);
1479
+ }
1480
+ if (assignedTo) {
1481
+ conditions.push(`assigned_to = $${idx++}`);
1482
+ values.push(assignedTo);
1483
+ }
1484
+ if (workflowId) {
1485
+ conditions.push(`workflow_id = $${idx++}`);
1486
+ values.push(workflowId);
1487
+ }
1488
+ if (originId) {
1489
+ conditions.push(`origin_id = $${idx++}`);
1490
+ values.push(originId);
1491
+ }
1492
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
1493
+ values.push(limit, offset);
1494
+ const result = await this.pgClient.query(`SELECT * FROM public.hmsh_escalations ${where} ORDER BY priority ASC, created_at ASC LIMIT $${idx++} OFFSET $${idx}`, values);
1495
+ return result.rows;
1496
+ }
1497
+ async claimEscalation(params) {
1498
+ const { id, namespace, assignee = '', durationMinutes = 1 } = params;
1499
+ // all_rows: reads the row without locking, so we can distinguish 'not-found' vs 'conflict'
1500
+ // target: attempts the lock; SKIP LOCKED means a locked row is skipped without error
1501
+ // claimed: performs the UPDATE only if target succeeded
1502
+ // The final SELECT always returns exactly 1 row via the dummy left-join — no second round-trip.
1503
+ const result = await this.pgClient.query(`
1504
+ WITH all_rows AS MATERIALIZED (
1505
+ SELECT id FROM public.hmsh_escalations
1506
+ WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''}
1507
+ ),
1508
+ target AS MATERIALIZED (
1509
+ SELECT id FROM public.hmsh_escalations
1510
+ WHERE id = $1
1511
+ ${namespace ? 'AND namespace = $4' : ''}
1512
+ AND status = 'pending'
1513
+ AND (assigned_to IS NULL OR claim_expires_at <= NOW() OR assigned_to = $2)
1514
+ LIMIT 1
1515
+ FOR UPDATE SKIP LOCKED
1516
+ ),
1517
+ claimed AS (
1518
+ UPDATE public.hmsh_escalations
1519
+ SET status = 'claimed',
1520
+ assigned_to = $2,
1521
+ claimed_at = NOW(),
1522
+ claim_expires_at = NOW() + ($3 * INTERVAL '1 minute'),
1523
+ updated_at = NOW()
1524
+ FROM target WHERE public.hmsh_escalations.id = target.id
1525
+ RETURNING public.hmsh_escalations.*
1526
+ )
1527
+ SELECT c.*, (SELECT EXISTS(SELECT 1 FROM all_rows)) AS row_exists
1528
+ FROM (VALUES(1)) AS dummy(v)
1529
+ LEFT JOIN claimed c ON true
1530
+ `, namespace ? [id, assignee, durationMinutes, namespace] : [id, assignee, durationMinutes]);
1531
+ const row = result.rows[0];
1532
+ if (row?.id)
1533
+ return { ok: true, entry: row };
1534
+ if (row?.row_exists)
1535
+ return { ok: false, reason: 'conflict' };
1536
+ return { ok: false, reason: 'not-found' };
1537
+ }
1538
+ async claimEscalationByMetadata(params) {
1539
+ const { key, value, namespace, assignee = '', durationMinutes = 1, roles } = params;
1540
+ const filter = JSON.stringify({ [key]: value });
1541
+ // all_candidates: counts ALL matching rows (any status) to distinguish 'not-found' vs 'conflict'
1542
+ // target: attempts the lock on the highest-priority pending row; SKIP LOCKED skips already-claimed ones
1543
+ // claimed: performs the UPDATE atomically from target
1544
+ // The dummy left-join ensures exactly 1 row is always returned so candidatesExist is always visible.
1545
+ const result = await this.pgClient.query(`
1546
+ WITH all_candidates AS MATERIALIZED (
1547
+ SELECT id FROM public.hmsh_escalations
1548
+ WHERE ${namespace ? 'namespace = $5 AND' : ''}
1549
+ metadata @> $1::jsonb
1550
+ AND ($4::text[] IS NULL OR role = ANY($4::text[]))
1551
+ ),
1552
+ target AS MATERIALIZED (
1553
+ SELECT id FROM public.hmsh_escalations
1554
+ WHERE ${namespace ? 'namespace = $5 AND' : ''}
1555
+ metadata @> $1::jsonb
1556
+ AND ($4::text[] IS NULL OR role = ANY($4::text[]))
1557
+ AND status = 'pending'
1558
+ AND (assigned_to IS NULL OR claim_expires_at <= NOW() OR assigned_to = $2)
1559
+ ORDER BY priority ASC, created_at ASC
1560
+ LIMIT 1
1561
+ FOR UPDATE SKIP LOCKED
1562
+ ),
1563
+ claimed AS (
1564
+ UPDATE public.hmsh_escalations
1565
+ SET status = 'claimed',
1566
+ assigned_to = $2,
1567
+ claimed_at = NOW(),
1568
+ claim_expires_at = NOW() + ($3 * INTERVAL '1 minute'),
1569
+ updated_at = NOW()
1570
+ FROM target WHERE public.hmsh_escalations.id = target.id
1571
+ RETURNING public.hmsh_escalations.*
1572
+ )
1573
+ SELECT c.*, (SELECT COUNT(*)::int FROM all_candidates) AS candidates_exist
1574
+ FROM (VALUES(1)) AS dummy(v)
1575
+ LEFT JOIN claimed c ON true
1576
+ `, namespace
1577
+ ? [filter, assignee, durationMinutes, roles ?? null, namespace]
1578
+ : [filter, assignee, durationMinutes, roles ?? null]);
1579
+ const row = result.rows[0];
1580
+ const candidatesExist = row?.candidates_exist ?? 0;
1581
+ if (row?.id)
1582
+ return { ok: true, entry: row, candidatesExist };
1583
+ return {
1584
+ ok: false,
1585
+ reason: candidatesExist > 0 ? 'conflict' : 'not-found',
1586
+ candidatesExist,
1587
+ };
1588
+ }
1589
+ async releaseEscalation(params) {
1590
+ const { id, namespace, assignee } = params;
1591
+ // Single CTE: SELECT FOR UPDATE locks the row, UPDATE releases it iff assignee matches.
1592
+ // Left-join exposes target_id even when the UPDATE did not fire (wrong-assignee case).
1593
+ // No second round-trip — assignee check and UPDATE are one atomic statement.
1594
+ const result = await this.pgClient.query(`
1595
+ WITH target AS MATERIALIZED (
1596
+ SELECT id, assigned_to FROM public.hmsh_escalations
1597
+ WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
1598
+ AND status = 'claimed'
1599
+ FOR UPDATE
1600
+ ),
1601
+ released AS (
1602
+ UPDATE public.hmsh_escalations
1603
+ SET status = 'pending', assigned_to = NULL, assigned_until = NULL,
1604
+ claimed_at = NULL, claim_expires_at = NULL, updated_at = NOW()
1605
+ FROM target
1606
+ WHERE public.hmsh_escalations.id = target.id
1607
+ AND ($2::text IS NULL OR target.assigned_to = $2)
1608
+ RETURNING public.hmsh_escalations.id
1609
+ )
1610
+ SELECT t.id AS target_id, t.assigned_to AS current_assignee, r.id AS released_id
1611
+ FROM target t
1612
+ LEFT JOIN released r ON r.id = t.id
1613
+ `, namespace ? [id, assignee ?? null, namespace] : [id, assignee ?? null]);
1614
+ const row = result.rows[0];
1615
+ if (!row)
1616
+ return { ok: false, reason: 'not-found' };
1617
+ if (!row.released_id)
1618
+ return { ok: false, reason: 'wrong-assignee' };
1619
+ return { ok: true };
1620
+ }
1621
+ async resolveEscalation(params) {
1622
+ const { id, namespace, resolverPayload } = params;
1623
+ const result = await this.pgClient.query(`
1624
+ WITH target AS MATERIALIZED (
1625
+ SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1626
+ WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
1627
+ LIMIT 1 FOR UPDATE
1628
+ ),
1629
+ resolved AS (
1630
+ UPDATE public.hmsh_escalations
1631
+ SET status = 'resolved',
1632
+ resolved_at = NOW(),
1633
+ resolver_payload = $2,
1634
+ updated_at = NOW()
1635
+ FROM target
1636
+ WHERE public.hmsh_escalations.id = target.id
1637
+ AND target.status IN ('pending', 'claimed')
1638
+ RETURNING public.hmsh_escalations.id
1639
+ )
1640
+ SELECT t.signal_key, t.topic, t.status AS prior_status,
1641
+ CASE
1642
+ WHEN r.id IS NOT NULL THEN 'resolved'
1643
+ WHEN t.id IS NULL THEN 'not-found'
1644
+ WHEN t.status = 'cancelled' THEN 'already-cancelled'
1645
+ ELSE 'already-resolved'
1646
+ END AS outcome
1647
+ FROM (SELECT * FROM target) t
1648
+ FULL OUTER JOIN (SELECT id FROM resolved) r ON r.id = t.id
1649
+ `, namespace
1650
+ ? [id, resolverPayload ? JSON.stringify(resolverPayload) : null, namespace]
1651
+ : [id, resolverPayload ? JSON.stringify(resolverPayload) : null]);
1652
+ if (!result.rows[0] || result.rows[0].outcome === 'not-found')
1653
+ return { ok: false, reason: 'not-found' };
1654
+ if (result.rows[0].outcome === 'already-cancelled')
1655
+ return { ok: false, reason: 'already-cancelled' };
1656
+ if (result.rows[0].outcome === 'already-resolved')
1657
+ return { ok: false, reason: 'already-resolved' };
1658
+ return { ok: true, signalKey: result.rows[0].signal_key, topic: result.rows[0].topic };
1659
+ }
1660
+ async resolveEscalationByMetadata(params) {
1661
+ const { key, value, namespace, resolverPayload, roles } = params;
1662
+ const filter = JSON.stringify({ [key]: value });
1663
+ const result = await this.pgClient.query(`
1664
+ WITH target AS MATERIALIZED (
1665
+ SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1666
+ WHERE ${namespace ? 'namespace = $4 AND' : ''}
1667
+ metadata @> $1::jsonb
1668
+ AND ($3::text[] IS NULL OR role = ANY($3::text[]))
1669
+ ORDER BY priority ASC, created_at ASC
1670
+ LIMIT 1 FOR UPDATE
1671
+ ),
1672
+ resolved AS (
1673
+ UPDATE public.hmsh_escalations
1674
+ SET status = 'resolved',
1675
+ resolved_at = NOW(),
1676
+ resolver_payload = $2,
1677
+ updated_at = NOW()
1678
+ FROM target
1679
+ WHERE public.hmsh_escalations.id = target.id
1680
+ AND target.status IN ('pending', 'claimed')
1681
+ RETURNING public.hmsh_escalations.id
1682
+ )
1683
+ SELECT t.signal_key, t.topic, t.status AS prior_status,
1684
+ CASE
1685
+ WHEN r.id IS NOT NULL THEN 'resolved'
1686
+ WHEN t.id IS NULL THEN 'not-found'
1687
+ WHEN t.status = 'cancelled' THEN 'already-cancelled'
1688
+ ELSE 'already-resolved'
1689
+ END AS outcome
1690
+ FROM (SELECT * FROM target) t
1691
+ FULL OUTER JOIN (SELECT id FROM resolved) r ON r.id = t.id
1692
+ `, namespace
1693
+ ? [filter, resolverPayload ? JSON.stringify(resolverPayload) : null, roles ?? null, namespace]
1694
+ : [filter, resolverPayload ? JSON.stringify(resolverPayload) : null, roles ?? null]);
1695
+ if (!result.rows[0] || result.rows[0].outcome === 'not-found')
1696
+ return { ok: false, reason: 'not-found' };
1697
+ if (result.rows[0].outcome === 'already-cancelled')
1698
+ return { ok: false, reason: 'already-cancelled' };
1699
+ if (result.rows[0].outcome === 'already-resolved')
1700
+ return { ok: false, reason: 'already-resolved' };
1701
+ return { ok: true, signalKey: result.rows[0].signal_key, topic: result.rows[0].topic };
1702
+ }
1703
+ /**
1704
+ * Queues the escalation UPDATE into an existing transaction without executing it.
1705
+ * Used by client.ts resolve() to commit the status change atomically with signal delivery.
1706
+ */
1707
+ queueResolveEscalation(params, transaction) {
1708
+ const { id, namespace, resolverPayload } = params;
1709
+ const sql = namespace
1710
+ ? `UPDATE public.hmsh_escalations
1711
+ SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2, updated_at = NOW()
1712
+ WHERE id = $1 AND namespace = $3 AND status IN ('pending', 'claimed')`
1713
+ : `UPDATE public.hmsh_escalations
1714
+ SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2, updated_at = NOW()
1715
+ WHERE id = $1 AND status IN ('pending', 'claimed')`;
1716
+ const sqlParams = namespace
1717
+ ? [id, resolverPayload ? JSON.stringify(resolverPayload) : null, namespace]
1718
+ : [id, resolverPayload ? JSON.stringify(resolverPayload) : null];
1719
+ transaction.addCommand(sql, sqlParams, 'number');
1720
+ }
1721
+ /**
1722
+ * Finds the highest-priority pending or claimed escalation matching the given metadata filter.
1723
+ * Used by client.ts resolveByMetadata() to get routing info before building the atomic transaction.
1724
+ */
1725
+ async findEscalationByMetadata(key, value, roles, namespace) {
1726
+ const filter = JSON.stringify({ [key]: value });
1727
+ const result = await this.pgClient.query(`
1728
+ SELECT * FROM public.hmsh_escalations
1729
+ WHERE ${namespace ? 'namespace = $3 AND' : ''}
1730
+ metadata @> $1::jsonb
1731
+ AND ($2::text[] IS NULL OR role = ANY($2::text[]))
1732
+ AND status IN ('pending', 'claimed')
1733
+ ORDER BY priority ASC, created_at ASC
1734
+ LIMIT 1
1735
+ `, namespace ? [filter, roles, namespace] : [filter, roles]);
1736
+ return result.rows[0] ?? null;
1737
+ }
1738
+ async cancelEscalation(id, namespace) {
1739
+ const result = await this.pgClient.query(`
1740
+ WITH target AS MATERIALIZED (
1741
+ SELECT id, status FROM public.hmsh_escalations
1742
+ WHERE id = $1 ${namespace ? 'AND namespace = $2' : ''}
1743
+ LIMIT 1 FOR UPDATE
1744
+ ),
1745
+ cancelled AS (
1746
+ UPDATE public.hmsh_escalations
1747
+ SET status = 'cancelled', updated_at = NOW()
1748
+ FROM target
1749
+ WHERE public.hmsh_escalations.id = target.id
1750
+ AND target.status IN ('pending', 'claimed')
1751
+ RETURNING public.hmsh_escalations.id
1752
+ )
1753
+ SELECT t.id, t.status AS prior_status,
1754
+ CASE
1755
+ WHEN c.id IS NOT NULL THEN 'cancelled'
1756
+ WHEN t.id IS NULL THEN 'not-found'
1757
+ ELSE 'already-terminal'
1758
+ END AS outcome
1759
+ FROM (SELECT * FROM target) t
1760
+ FULL OUTER JOIN (SELECT id FROM cancelled) c ON c.id = t.id
1761
+ `, namespace ? [id, namespace] : [id]);
1762
+ if (!result.rows[0] || result.rows[0].outcome === 'not-found')
1763
+ return { ok: false, reason: 'not-found' };
1764
+ if (result.rows[0].outcome === 'already-terminal')
1765
+ return { ok: false, reason: 'already-terminal' };
1766
+ return { ok: true };
1767
+ }
1768
+ async escalateEscalationToRole(params) {
1769
+ const { id, targetRole, namespace } = params;
1770
+ const result = await this.pgClient.query(`
1771
+ UPDATE public.hmsh_escalations
1772
+ SET role = $2,
1773
+ status = 'pending',
1774
+ assigned_to = NULL,
1775
+ assigned_until = NULL,
1776
+ claimed_at = NULL,
1777
+ claim_expires_at = NULL,
1778
+ updated_at = NOW()
1779
+ WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
1780
+ AND status IN ('pending', 'claimed')
1781
+ RETURNING *
1782
+ `, namespace ? [id, targetRole, namespace] : [id, targetRole]);
1783
+ return result.rows[0] ?? null;
1784
+ }
1785
+ async updateEscalation(params) {
1786
+ const { id, namespace, description, priority, role, metadata, envelope, signalKey, topic, workflowId, taskQueue, workflowType, expiresAt } = params;
1787
+ const sets = [];
1788
+ const values = [id];
1789
+ let idx = 2;
1790
+ if (description !== undefined) {
1791
+ sets.push(`description = $${idx++}`);
1792
+ values.push(description);
1793
+ }
1794
+ if (priority !== undefined) {
1795
+ sets.push(`priority = $${idx++}`);
1796
+ values.push(priority);
1797
+ }
1798
+ if (role !== undefined) {
1799
+ sets.push(`role = $${idx++}`);
1800
+ values.push(role);
1801
+ }
1802
+ if (envelope !== undefined) {
1803
+ sets.push(`envelope = $${idx++}`);
1804
+ values.push(JSON.stringify(envelope));
1805
+ }
1806
+ if (signalKey !== undefined) {
1807
+ sets.push(`signal_key = $${idx++}`);
1808
+ values.push(signalKey);
1809
+ }
1810
+ if (topic !== undefined) {
1811
+ sets.push(`topic = $${idx++}`);
1812
+ values.push(topic);
1813
+ }
1814
+ if (workflowId !== undefined) {
1815
+ sets.push(`workflow_id = $${idx++}`);
1816
+ values.push(workflowId);
1817
+ }
1818
+ if (taskQueue !== undefined) {
1819
+ sets.push(`task_queue = $${idx++}`);
1820
+ values.push(taskQueue);
1821
+ }
1822
+ if (workflowType !== undefined) {
1823
+ sets.push(`workflow_type = $${idx++}`);
1824
+ values.push(workflowType);
1825
+ }
1826
+ if (expiresAt !== undefined) {
1827
+ sets.push(`expires_at = $${idx++}`);
1828
+ values.push(expiresAt);
1829
+ }
1830
+ // Metadata is merged (not replaced) — caller patches individual keys
1831
+ if (metadata !== undefined) {
1832
+ sets.push(`metadata = COALESCE(metadata, '{}'::jsonb) || $${idx++}::jsonb`);
1833
+ values.push(JSON.stringify(metadata));
1834
+ }
1835
+ if (!sets.length)
1836
+ return this.getEscalation(id, namespace);
1837
+ sets.push(`updated_at = NOW()`);
1838
+ const nsClause = namespace ? ` AND namespace = $${idx++}` : '';
1839
+ if (namespace)
1840
+ values.push(namespace);
1841
+ const result = await this.pgClient.query(`UPDATE public.hmsh_escalations SET ${sets.join(', ')} WHERE id = $1${nsClause} RETURNING *`, values);
1842
+ return result.rows[0] ?? null;
1843
+ }
1844
+ async appendEscalationMilestones(params) {
1845
+ const { id, namespace, milestones } = params;
1846
+ const stamped = milestones.map(m => ({ ...m, created_at: new Date().toISOString() }));
1847
+ const result = await this.pgClient.query(`
1848
+ UPDATE public.hmsh_escalations
1849
+ SET milestones = milestones || $2::jsonb,
1850
+ updated_at = NOW()
1851
+ WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''}
1852
+ RETURNING *
1853
+ `, namespace ? [id, JSON.stringify(stamped), namespace] : [id, JSON.stringify(stamped)]);
1854
+ return result.rows[0] ?? null;
1855
+ }
1856
+ async releaseExpiredEscalations(namespace) {
1857
+ const result = await this.pgClient.query(`
1858
+ UPDATE public.hmsh_escalations
1859
+ SET status = 'pending', assigned_to = NULL, assigned_until = NULL,
1860
+ claimed_at = NULL, claim_expires_at = NULL, updated_at = NOW()
1861
+ WHERE status = 'claimed'
1862
+ AND claim_expires_at <= NOW()
1863
+ ${namespace ? 'AND namespace = $1' : ''}
1864
+ `, namespace ? [namespace] : []);
1865
+ return result.rowCount ?? 0;
1866
+ }
1665
1867
  }
1666
1868
  exports.PostgresStoreService = PostgresStoreService;