@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.
Files changed (32) hide show
  1. package/README.md +12 -129
  2. package/build/modules/utils.d.ts +2 -0
  3. package/build/modules/utils.js +9 -1
  4. package/build/package.json +8 -2
  5. package/build/services/activities/hook.d.ts +178 -58
  6. package/build/services/activities/hook.js +244 -58
  7. package/build/services/activities/trigger.js +5 -1
  8. package/build/services/durable/client.d.ts +273 -67
  9. package/build/services/durable/client.js +351 -126
  10. package/build/services/durable/index.d.ts +7 -3
  11. package/build/services/durable/index.js +6 -0
  12. package/build/services/durable/schemas/factory.js +40 -0
  13. package/build/services/durable/worker.js +5 -28
  14. package/build/services/durable/workflow/condition.d.ts +69 -37
  15. package/build/services/durable/workflow/condition.js +70 -39
  16. package/build/services/hotmesh/index.d.ts +31 -4
  17. package/build/services/hotmesh/index.js +31 -4
  18. package/build/services/store/index.d.ts +1 -1
  19. package/build/services/store/providers/postgres/kvsql.d.ts +1 -1
  20. package/build/services/store/providers/postgres/kvtables.js +83 -122
  21. package/build/services/store/providers/postgres/kvtypes/hash/basic.d.ts +1 -1
  22. package/build/services/store/providers/postgres/kvtypes/hash/basic.js +8 -8
  23. package/build/services/store/providers/postgres/kvtypes/hash/index.d.ts +1 -1
  24. package/build/services/store/providers/postgres/postgres.d.ts +51 -188
  25. package/build/services/store/providers/postgres/postgres.js +542 -285
  26. package/build/types/activity.d.ts +2 -0
  27. package/build/types/hmsh_escalations.d.ts +240 -0
  28. package/build/types/index.d.ts +1 -1
  29. package/build/types/provider.d.ts +2 -0
  30. package/package.json +9 -2
  31. package/build/types/signal.d.ts +0 -147
  32. /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;