@hotmeshio/hotmesh 0.22.2 → 0.22.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/index.d.ts +1 -0
- package/build/package.json +3 -1
- package/build/services/activities/hook.js +34 -5
- package/build/services/durable/client.d.ts +12 -0
- package/build/services/durable/client.js +13 -1
- package/build/services/durable/worker.d.ts +10 -0
- package/build/services/durable/worker.js +33 -0
- package/build/services/escalations/client.d.ts +69 -12
- package/build/services/escalations/client.js +137 -19
- package/build/services/hotmesh/index.d.ts +9 -0
- package/build/services/hotmesh/index.js +37 -0
- package/build/services/store/providers/postgres/kvtables.js +165 -6
- package/build/services/store/providers/postgres/postgres.d.ts +12 -16
- package/build/services/store/providers/postgres/postgres.js +279 -207
- package/build/types/durable.d.ts +11 -0
- package/build/types/hmsh_escalations.d.ts +71 -0
- package/build/types/hotmesh.d.ts +17 -0
- package/build/types/index.d.ts +1 -0
- package/build/types/system_events.d.ts +178 -0
- package/build/types/system_events.js +104 -0
- package/index.ts +1 -0
- package/package.json +3 -2
package/build/index.d.ts
CHANGED
|
@@ -22,3 +22,4 @@ import { Escalations } from './services/escalations';
|
|
|
22
22
|
export { Connector, //factory
|
|
23
23
|
ConnectorNATS, ConnectorPostgres, HotMesh, HotMeshConfig, Virtual, Durable, Escalations, DBA, Client, Connection, proxyActivities, Search, Entity, Worker, workflow, WorkflowHandle, Enums, Errors, Utils, KeyStore, };
|
|
24
24
|
export * as Types from './types';
|
|
25
|
+
export type { EventsConfig, SystemEvent } from './types/system_events';
|
package/build/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hotmeshio/hotmesh",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.4",
|
|
4
4
|
"description": "Durable Workflow",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"types": "./build/index.d.ts",
|
|
@@ -88,6 +88,8 @@
|
|
|
88
88
|
"test:unit": "vitest run tests/unit",
|
|
89
89
|
"prove": "docker compose exec hotmesh npx vitest run tests/durable 2>&1 | tee /tmp/hmsh-durable.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-durable.txt | tail -5",
|
|
90
90
|
"prove:escalations": "docker compose exec hotmesh npx vitest run tests/durable/escalations/postgres.test.ts 2>&1 | tee /tmp/hmsh-escalations.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-escalations.txt | tail -5",
|
|
91
|
+
"prove:migrations": "docker compose exec hotmesh npx vitest run tests/durable/migrations/postgres.test.ts 2>&1 | tee /tmp/hmsh-migrations.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-migrations.txt | tail -5",
|
|
92
|
+
"prove:events": "docker compose exec hotmesh npx vitest run tests/durable/events/postgres.test.ts 2>&1 | tee /tmp/hmsh-events.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-events.txt | tail -5",
|
|
91
93
|
"prove:functional": "docker compose exec hotmesh npx vitest run tests/functional 2>&1 | tee /tmp/hmsh-functional.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-functional.txt | tail -5",
|
|
92
94
|
"prove:all": "docker compose exec hotmesh npx vitest run tests/ 2>&1 | tee /tmp/hmsh-all.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-all.txt | tail -5",
|
|
93
95
|
"prove:file": "f() { docker compose exec hotmesh npx vitest run \"$@\" 2>&1 | tee /tmp/hmsh-file.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-file.txt | tail -5; }; f"
|
|
@@ -347,8 +347,10 @@ class Hook extends activity_1.Activity {
|
|
|
347
347
|
//enqueue escalation INSERT inside the Leg1 transaction so it is
|
|
348
348
|
//written atomically with the job state checkpoint — one committed
|
|
349
349
|
//unit, crash-safe, no separate recovery path needed.
|
|
350
|
-
await this.addEscalationToTransaction(transaction);
|
|
351
|
-
|
|
350
|
+
const escalationSignalKey = await this.addEscalationToTransaction(transaction);
|
|
351
|
+
// exec() returns one result per queued command; the escalation INSERT
|
|
352
|
+
// (with RETURNING *) is the last command — its row is results[last].
|
|
353
|
+
const execResults = await transaction.exec();
|
|
352
354
|
telemetry.mapActivityAttributes();
|
|
353
355
|
//register the web hook signal AFTER the transaction commits.
|
|
354
356
|
//this eliminates the FORBIDDEN window: the hook signal is never
|
|
@@ -360,13 +362,39 @@ class Hook extends activity_1.Activity {
|
|
|
360
362
|
if (pending) {
|
|
361
363
|
await this.redeliverPendingSignal(pending);
|
|
362
364
|
}
|
|
365
|
+
// Post-commit: fire the escalation.created event using the row returned
|
|
366
|
+
// directly from the RETURNING * clause — no extra SELECT.
|
|
367
|
+
if (escalationSignalKey) {
|
|
368
|
+
const store = this.store;
|
|
369
|
+
if (store.eventsPublish) {
|
|
370
|
+
const row = execResults[execResults.length - 1];
|
|
371
|
+
if (row?.id) {
|
|
372
|
+
const ts = new Date().toISOString();
|
|
373
|
+
const updatedAt = row.updated_at ? new Date(row.updated_at).toISOString() : ts;
|
|
374
|
+
void Promise.resolve(store.eventsPublish({
|
|
375
|
+
event_id: `${row.id}:created:${updatedAt}`,
|
|
376
|
+
type: `system.escalation.${row.id}.created`,
|
|
377
|
+
ts,
|
|
378
|
+
namespace: row.namespace ?? this.engine.namespace ?? this.engine.appId,
|
|
379
|
+
app_id: row.app_id ?? this.engine.appId,
|
|
380
|
+
workflow_id: row.workflow_id ?? undefined,
|
|
381
|
+
topic: row.topic ?? undefined,
|
|
382
|
+
origin_id: row.origin_id ?? undefined,
|
|
383
|
+
parent_id: row.parent_id ?? undefined,
|
|
384
|
+
trace_id: row.trace_id ?? undefined,
|
|
385
|
+
span_id: row.span_id ?? undefined,
|
|
386
|
+
data: row,
|
|
387
|
+
})).catch(() => { });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
363
391
|
}
|
|
364
392
|
async addEscalationToTransaction(transaction) {
|
|
365
393
|
if (!this.config.escalation || !this.config.hook?.topic)
|
|
366
|
-
return;
|
|
394
|
+
return null;
|
|
367
395
|
const store = this.store;
|
|
368
396
|
if (typeof store.addEscalationToTransaction !== 'function')
|
|
369
|
-
return;
|
|
397
|
+
return null;
|
|
370
398
|
const escalationConfig = this.config.escalation;
|
|
371
399
|
const jid = this.context.metadata.jid;
|
|
372
400
|
const appId = this.engine.appId;
|
|
@@ -408,7 +436,7 @@ class Hook extends activity_1.Activity {
|
|
|
408
436
|
// the factory waiter runs for a condition() call that had no queueConfig.
|
|
409
437
|
if (params.role == null && params.type == null &&
|
|
410
438
|
params.priority == null && params.metadata == null)
|
|
411
|
-
return;
|
|
439
|
+
return null;
|
|
412
440
|
// Derive signal_key from the hook rule's expected condition — the same
|
|
413
441
|
// value registerWebHook stores as the signal lookup key.
|
|
414
442
|
const hookRule = await this.getHookRule(this.config.hook.topic);
|
|
@@ -423,6 +451,7 @@ class Hook extends activity_1.Activity {
|
|
|
423
451
|
workflowId: jid,
|
|
424
452
|
...params,
|
|
425
453
|
}, transaction);
|
|
454
|
+
return signalKey;
|
|
426
455
|
}
|
|
427
456
|
/**
|
|
428
457
|
* Re-publishes a pending signal as a WEBHOOK stream message so the
|
|
@@ -4,6 +4,11 @@ import { EscalationClientService } from '../escalations/client';
|
|
|
4
4
|
/**
|
|
5
5
|
* Workflow client. Starts workflows, sends signals, and reads results.
|
|
6
6
|
*
|
|
7
|
+
* Pass `config.events` to receive system-event notifications from all
|
|
8
|
+
* escalation operations performed through this client. Events fire
|
|
9
|
+
* post-commit, from this process only — no fanout to other containers.
|
|
10
|
+
* See `EventsConfig` and `SystemEvent` in `types/system_events` for the full ontology.
|
|
11
|
+
*
|
|
7
12
|
* @example
|
|
8
13
|
* ```typescript
|
|
9
14
|
* import { Durable } from '@hotmeshio/hotmesh';
|
|
@@ -14,6 +19,13 @@ import { EscalationClientService } from '../escalations/client';
|
|
|
14
19
|
* class: Postgres,
|
|
15
20
|
* options: { connectionString: 'postgresql://usr:pwd@localhost:5432/db' },
|
|
16
21
|
* },
|
|
22
|
+
* // optional — wire lifecycle events
|
|
23
|
+
* events: {
|
|
24
|
+
* publish: (event) => {
|
|
25
|
+
* // event.type follows system.escalation.{id}.{verb}
|
|
26
|
+
* myEventBus.emit(event.type, event.data);
|
|
27
|
+
* },
|
|
28
|
+
* },
|
|
17
29
|
* });
|
|
18
30
|
*
|
|
19
31
|
* // Start a workflow and await its result
|
|
@@ -14,6 +14,11 @@ const factory_1 = require("./schemas/factory");
|
|
|
14
14
|
/**
|
|
15
15
|
* Workflow client. Starts workflows, sends signals, and reads results.
|
|
16
16
|
*
|
|
17
|
+
* Pass `config.events` to receive system-event notifications from all
|
|
18
|
+
* escalation operations performed through this client. Events fire
|
|
19
|
+
* post-commit, from this process only — no fanout to other containers.
|
|
20
|
+
* See `EventsConfig` and `SystemEvent` in `types/system_events` for the full ontology.
|
|
21
|
+
*
|
|
17
22
|
* @example
|
|
18
23
|
* ```typescript
|
|
19
24
|
* import { Durable } from '@hotmeshio/hotmesh';
|
|
@@ -24,6 +29,13 @@ const factory_1 = require("./schemas/factory");
|
|
|
24
29
|
* class: Postgres,
|
|
25
30
|
* options: { connectionString: 'postgresql://usr:pwd@localhost:5432/db' },
|
|
26
31
|
* },
|
|
32
|
+
* // optional — wire lifecycle events
|
|
33
|
+
* events: {
|
|
34
|
+
* publish: (event) => {
|
|
35
|
+
* // event.type follows system.escalation.{id}.{verb}
|
|
36
|
+
* myEventBus.emit(event.type, event.data);
|
|
37
|
+
* },
|
|
38
|
+
* },
|
|
27
39
|
* });
|
|
28
40
|
*
|
|
29
41
|
* // Start a workflow and await its result
|
|
@@ -286,9 +298,9 @@ class ClientService {
|
|
|
286
298
|
},
|
|
287
299
|
};
|
|
288
300
|
this.connection = config.connection;
|
|
289
|
-
// Inject our getHotMeshClient so the escalation client shares the same engine pool.
|
|
290
301
|
this.escalations = new client_1.EscalationClientService({
|
|
291
302
|
getHotMeshClient: this.getHotMeshClient.bind(this),
|
|
303
|
+
events: config.events,
|
|
292
304
|
});
|
|
293
305
|
}
|
|
294
306
|
hashOptions() {
|
|
@@ -104,6 +104,12 @@ export declare class WorkerService {
|
|
|
104
104
|
* @private
|
|
105
105
|
*/
|
|
106
106
|
activityRunner: HotMesh;
|
|
107
|
+
/** @private — retained from create() config for stop() lifecycle event */
|
|
108
|
+
_eventsPublish?: (event: import('../../types/system_events').SystemEvent) => void | Promise<void>;
|
|
109
|
+
/** @private */
|
|
110
|
+
_eventsTaskQueue?: string;
|
|
111
|
+
/** @private */
|
|
112
|
+
_eventsAppId?: string;
|
|
107
113
|
/**
|
|
108
114
|
* @private
|
|
109
115
|
*/
|
|
@@ -268,6 +274,10 @@ export declare class WorkerService {
|
|
|
268
274
|
* Run the connected worker; no-op (unnecessary to call)
|
|
269
275
|
*/
|
|
270
276
|
run(): Promise<void>;
|
|
277
|
+
/**
|
|
278
|
+
* Stops the worker's HotMesh instances and fires `system.worker.{taskQueue}.stopped`.
|
|
279
|
+
*/
|
|
280
|
+
stop(): void;
|
|
271
281
|
/**
|
|
272
282
|
* @private
|
|
273
283
|
*/
|
|
@@ -478,6 +478,21 @@ class WorkerService {
|
|
|
478
478
|
worker.workflowRunner = await worker.initWorkflowWorker(config, taskQueue, workflowFunctionName, workflowTopic, workflowFunction);
|
|
479
479
|
search_1.Search.configureSearchIndex(worker.workflowRunner, config.search);
|
|
480
480
|
await WorkerService.activateWorkflow(worker.workflowRunner);
|
|
481
|
+
// Fire system.worker.{taskQueue}.started post-init (best-effort).
|
|
482
|
+
if (config.events?.publish) {
|
|
483
|
+
worker._eventsPublish = config.events.publish;
|
|
484
|
+
worker._eventsTaskQueue = taskQueue;
|
|
485
|
+
worker._eventsAppId = targetNamespace;
|
|
486
|
+
const ts = new Date().toISOString();
|
|
487
|
+
void Promise.resolve(config.events.publish({
|
|
488
|
+
event_id: `${taskQueue}:started:${ts}`,
|
|
489
|
+
type: `system.worker.${taskQueue}.started`,
|
|
490
|
+
ts,
|
|
491
|
+
namespace: targetNamespace,
|
|
492
|
+
app_id: targetNamespace,
|
|
493
|
+
data: { taskQueue, appId: targetNamespace },
|
|
494
|
+
})).catch(() => { });
|
|
495
|
+
}
|
|
481
496
|
return worker;
|
|
482
497
|
}
|
|
483
498
|
/**
|
|
@@ -502,6 +517,24 @@ class WorkerService {
|
|
|
502
517
|
async run() {
|
|
503
518
|
this.workflowRunner.engine.logger.info('durable-worker-running');
|
|
504
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Stops the worker's HotMesh instances and fires `system.worker.{taskQueue}.stopped`.
|
|
522
|
+
*/
|
|
523
|
+
stop() {
|
|
524
|
+
this.workflowRunner?.stop();
|
|
525
|
+
this.activityRunner?.stop();
|
|
526
|
+
if (this._eventsPublish && this._eventsTaskQueue) {
|
|
527
|
+
const ts = new Date().toISOString();
|
|
528
|
+
void Promise.resolve(this._eventsPublish({
|
|
529
|
+
event_id: `${this._eventsTaskQueue}:stopped:${ts}`,
|
|
530
|
+
type: `system.worker.${this._eventsTaskQueue}.stopped`,
|
|
531
|
+
ts,
|
|
532
|
+
namespace: this._eventsAppId ?? '',
|
|
533
|
+
app_id: this._eventsAppId ?? '',
|
|
534
|
+
data: { taskQueue: this._eventsTaskQueue, appId: this._eventsAppId },
|
|
535
|
+
})).catch(() => { });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
505
538
|
/**
|
|
506
539
|
* @private
|
|
507
540
|
*/
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { HotMesh } from '../hotmesh';
|
|
2
|
+
import { EventsConfig } from '../../types/system_events';
|
|
2
3
|
import { Connection } from '../../types/durable';
|
|
3
|
-
import { EscalationEntry, ClaimEscalationResult, ClaimByMetadataResult, ReleaseEscalationResult, ResolveEscalationResult, CancelEscalationResult, ListEscalationsParams, CreateEscalationParams, UpdateEscalationParams, AppendMilestonesParams, ClaimEscalationParams, ClaimByMetadataParams, ReleaseEscalationParams, ResolveEscalationParams, ResolveByMetadataParams, EscalateToRoleParams, MigrateEscalationParams } from '../../types/hmsh_escalations';
|
|
4
|
-
type GetHotMeshFn = (topic: string | null, namespace?: string) => Promise<HotMesh>;
|
|
4
|
+
import { EscalationEntry, ClaimEscalationResult, ClaimByMetadataResult, ReleaseEscalationResult, ResolveEscalationResult, CancelEscalationResult, ListEscalationsParams, StatsEscalationsParams, EscalationStats, CreateEscalationParams, UpdateEscalationParams, AppendMilestonesParams, ClaimEscalationParams, ClaimByMetadataParams, ReleaseEscalationParams, ResolveEscalationParams, ResolveByMetadataParams, EscalateToRoleParams, MigrateEscalationParams, ClaimManyParams, EscalateManyToRoleParams, UpdateManyPriorityParams, ResolveManyParams } from '../../types/hmsh_escalations';
|
|
5
|
+
export type GetHotMeshFn = (topic: string | null, namespace?: string) => Promise<HotMesh>;
|
|
5
6
|
export interface EscalationClientConfig {
|
|
6
7
|
/** Postgres connection options — used when creating a standalone EscalationClient. */
|
|
7
8
|
connection?: Connection;
|
|
8
9
|
/**
|
|
9
10
|
* Inject a pre-existing `getHotMeshClient` function (e.g. from Durable.Client).
|
|
10
|
-
* When provided, the client reuses the caller's engine pool — no
|
|
11
|
+
* When provided, the client reuses the caller's engine pool — no extra connections.
|
|
11
12
|
*/
|
|
12
13
|
getHotMeshClient?: GetHotMeshFn;
|
|
14
|
+
/**
|
|
15
|
+
* Optional system-event sink. When set, this client calls `events.publish`
|
|
16
|
+
* post-commit for every escalation lifecycle transition it performs.
|
|
17
|
+
* Fire-and-forget; a publish error never fails the committed operation.
|
|
18
|
+
*/
|
|
19
|
+
events?: EventsConfig;
|
|
13
20
|
}
|
|
14
21
|
/**
|
|
15
22
|
* Standalone client for the `public.hmsh_escalations` signal-pause surface.
|
|
@@ -48,16 +55,27 @@ export interface EscalationClientConfig {
|
|
|
48
55
|
*/
|
|
49
56
|
export declare class EscalationClientService {
|
|
50
57
|
private readonly _engine;
|
|
51
|
-
private readonly
|
|
58
|
+
private readonly _events?;
|
|
52
59
|
static instances: Map<string, HotMesh | Promise<HotMesh>>;
|
|
53
60
|
constructor(config?: EscalationClientConfig);
|
|
61
|
+
/**
|
|
62
|
+
* Fires the configured `events.publish` hook post-commit.
|
|
63
|
+
* Detached (not awaited); a publish error never fails the caller.
|
|
64
|
+
* @private
|
|
65
|
+
*/
|
|
66
|
+
private _emit;
|
|
67
|
+
/**
|
|
68
|
+
* Fires per-row events for bulk operations. Skipped rows are not emitted.
|
|
69
|
+
* @private
|
|
70
|
+
*/
|
|
71
|
+
private _emitMany;
|
|
54
72
|
private _makeEngineFactory;
|
|
55
73
|
private _hashConnection;
|
|
56
74
|
private _deliverEscalationSignal;
|
|
57
75
|
/**
|
|
58
76
|
* Returns all escalation rows matching the given filters. Each row includes
|
|
59
77
|
* a computed `available` field (true = claimable). Supports `sortBy`,
|
|
60
|
-
* `sortOrder`, and multi-role `roles[]` filter.
|
|
78
|
+
* `sortOrder`, `orderBy[]`, and multi-role `roles[]` filter.
|
|
61
79
|
*/
|
|
62
80
|
list(params?: ListEscalationsParams): Promise<EscalationEntry[]>;
|
|
63
81
|
/**
|
|
@@ -84,12 +102,14 @@ export declare class EscalationClientService {
|
|
|
84
102
|
/**
|
|
85
103
|
* Atomically claims an escalation by UUID. Implicit model: `status` stays
|
|
86
104
|
* `'pending'`; claim is expressed via `assigned_to` + `assigned_until`.
|
|
105
|
+
* Returns `isExtension: true` when the same assignee re-claims a row they already hold.
|
|
87
106
|
*/
|
|
88
107
|
claim(params: ClaimEscalationParams): Promise<ClaimEscalationResult>;
|
|
89
108
|
/**
|
|
90
109
|
* Atomically claims the highest-priority pending escalation whose `metadata`
|
|
91
|
-
* contains the given key/value.
|
|
92
|
-
*
|
|
110
|
+
* contains the given key/value. Optionally merges `metadata` into the claimed row
|
|
111
|
+
* in the same atomic UPDATE. Returns `isExtension: true` when the same assignee
|
|
112
|
+
* re-claims a row they already hold (extends the expiry).
|
|
93
113
|
*/
|
|
94
114
|
claimByMetadata(params: ClaimByMetadataParams): Promise<ClaimByMetadataResult>;
|
|
95
115
|
/** Releases a claimed escalation, returning it to available status. */
|
|
@@ -105,14 +125,17 @@ export declare class EscalationClientService {
|
|
|
105
125
|
*/
|
|
106
126
|
cancel(id: string, namespace?: string): Promise<CancelEscalationResult>;
|
|
107
127
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
128
|
+
* Resolves a pending escalation by UUID. Uses an explicit Postgres transaction
|
|
129
|
+
* with FOR UPDATE + WHERE guard: only one concurrent caller can commit the
|
|
130
|
+
* status change; the committed resolved row with its `signal_key` is the
|
|
131
|
+
* durable proof. Signal delivery is best-effort post-commit — the resolved
|
|
132
|
+
* row is the recovery record for any missed delivery. Returns the updated
|
|
133
|
+
* row as `entry` on success.
|
|
111
134
|
*/
|
|
112
135
|
resolve(params: ResolveEscalationParams, namespace?: string): Promise<ResolveEscalationResult>;
|
|
113
136
|
/**
|
|
114
137
|
* Resolves the highest-priority matching escalation by metadata filter,
|
|
115
|
-
* then delivers its signal.
|
|
138
|
+
* then delivers its signal. Same transaction + WHERE guard semantics as `resolve()`.
|
|
116
139
|
*/
|
|
117
140
|
resolveByMetadata(params: ResolveByMetadataParams, namespace?: string): Promise<ResolveEscalationResult>;
|
|
118
141
|
/**
|
|
@@ -125,6 +148,40 @@ export declare class EscalationClientService {
|
|
|
125
148
|
* from `assigned_until`. Kept for API compatibility.
|
|
126
149
|
*/
|
|
127
150
|
releaseExpired(namespace?: string): Promise<number>;
|
|
151
|
+
/**
|
|
152
|
+
* Bulk-claims up to `ids.length` pending escalations in one statement.
|
|
153
|
+
* Returns `{ claimed, skipped }` — skipped rows are either already claimed
|
|
154
|
+
* by another assignee or non-existent. Implicit-claim semantics apply.
|
|
155
|
+
*/
|
|
156
|
+
claimMany(params: ClaimManyParams): Promise<{
|
|
157
|
+
claimed: number;
|
|
158
|
+
skipped: number;
|
|
159
|
+
}>;
|
|
160
|
+
/**
|
|
161
|
+
* Bulk-reassigns pending escalations to a new role, clearing any current claim.
|
|
162
|
+
* Returns the count of rows updated.
|
|
163
|
+
*/
|
|
164
|
+
escalateManyToRole(params: EscalateManyToRoleParams): Promise<number>;
|
|
165
|
+
/**
|
|
166
|
+
* Bulk-updates priority for pending escalations. Returns the count of rows updated.
|
|
167
|
+
*/
|
|
168
|
+
updateManyPriority(params: UpdateManyPriorityParams): Promise<number>;
|
|
169
|
+
/**
|
|
170
|
+
* Bulk-resolves pending escalations by id-set. No signal delivery — intended
|
|
171
|
+
* for redirect-to-triage flows where no workflow is waiting. Returns the
|
|
172
|
+
* resolved rows.
|
|
173
|
+
*/
|
|
174
|
+
resolveMany(params: ResolveManyParams): Promise<EscalationEntry[]>;
|
|
175
|
+
/**
|
|
176
|
+
* Returns dashboard-ready escalation counts. `period` controls the window
|
|
177
|
+
* used for `created` and `resolved` counts (default `'24h'`). When `roles`
|
|
178
|
+
* is an empty array, all counts are zero (RBAC guard).
|
|
179
|
+
*/
|
|
180
|
+
stats(params?: StatsEscalationsParams): Promise<EscalationStats>;
|
|
181
|
+
/**
|
|
182
|
+
* Returns the sorted list of distinct `type` values in the escalations table.
|
|
183
|
+
* Useful for populating filter dropdowns.
|
|
184
|
+
*/
|
|
185
|
+
listDistinctTypes(namespace?: string): Promise<string[]>;
|
|
128
186
|
static shutdown(): Promise<void>;
|
|
129
187
|
}
|
|
130
|
-
export {};
|
|
@@ -44,16 +44,51 @@ const factory_1 = require("../durable/schemas/factory");
|
|
|
44
44
|
class EscalationClientService {
|
|
45
45
|
constructor(config = {}) {
|
|
46
46
|
if (config.getHotMeshClient) {
|
|
47
|
-
// Reuse a caller-supplied engine factory (e.g. Durable.Client) — no extra connections.
|
|
48
47
|
this._engine = config.getHotMeshClient;
|
|
49
48
|
}
|
|
50
49
|
else if (config.connection) {
|
|
51
|
-
this._connection = config.connection;
|
|
52
50
|
this._engine = this._makeEngineFactory(config.connection);
|
|
53
51
|
}
|
|
54
52
|
else {
|
|
55
53
|
throw new Error('EscalationClient requires either `connection` or `getHotMeshClient`');
|
|
56
54
|
}
|
|
55
|
+
this._events = config.events;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Fires the configured `events.publish` hook post-commit.
|
|
59
|
+
* Detached (not awaited); a publish error never fails the caller.
|
|
60
|
+
* @private
|
|
61
|
+
*/
|
|
62
|
+
_emit(verb, entry) {
|
|
63
|
+
if (!this._events?.publish)
|
|
64
|
+
return;
|
|
65
|
+
const ts = new Date().toISOString();
|
|
66
|
+
const updatedAt = entry.updated_at
|
|
67
|
+
? (0, utils_1.formatISODate)(entry.updated_at)
|
|
68
|
+
: ts;
|
|
69
|
+
const event = {
|
|
70
|
+
event_id: `${entry.id}:${verb}:${updatedAt}`,
|
|
71
|
+
type: `system.escalation.${entry.id}.${verb}`,
|
|
72
|
+
ts,
|
|
73
|
+
namespace: entry.namespace ?? '',
|
|
74
|
+
app_id: entry.app_id ?? '',
|
|
75
|
+
workflow_id: entry.workflow_id ?? undefined,
|
|
76
|
+
topic: entry.topic ?? undefined,
|
|
77
|
+
origin_id: entry.origin_id ?? undefined,
|
|
78
|
+
parent_id: entry.parent_id ?? undefined,
|
|
79
|
+
trace_id: entry.trace_id ?? undefined,
|
|
80
|
+
span_id: entry.span_id ?? undefined,
|
|
81
|
+
data: entry,
|
|
82
|
+
};
|
|
83
|
+
void Promise.resolve(this._events.publish(event)).catch(() => { });
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Fires per-row events for bulk operations. Skipped rows are not emitted.
|
|
87
|
+
* @private
|
|
88
|
+
*/
|
|
89
|
+
_emitMany(verb, entries) {
|
|
90
|
+
for (const entry of entries)
|
|
91
|
+
this._emit(verb, entry);
|
|
57
92
|
}
|
|
58
93
|
_makeEngineFactory(connection) {
|
|
59
94
|
return async (topic, namespace) => {
|
|
@@ -114,7 +149,7 @@ class EscalationClientService {
|
|
|
114
149
|
/**
|
|
115
150
|
* Returns all escalation rows matching the given filters. Each row includes
|
|
116
151
|
* a computed `available` field (true = claimable). Supports `sortBy`,
|
|
117
|
-
* `sortOrder`, and multi-role `roles[]` filter.
|
|
152
|
+
* `sortOrder`, `orderBy[]`, and multi-role `roles[]` filter.
|
|
118
153
|
*/
|
|
119
154
|
async list(params) {
|
|
120
155
|
const hm = await this._engine(null, params?.namespace);
|
|
@@ -144,7 +179,10 @@ class EscalationClientService {
|
|
|
144
179
|
*/
|
|
145
180
|
async create(params) {
|
|
146
181
|
const hm = await this._engine(null, params.namespace);
|
|
147
|
-
|
|
182
|
+
const entry = await hm.engine.store.createEscalation(params);
|
|
183
|
+
if (entry)
|
|
184
|
+
this._emit('created', entry);
|
|
185
|
+
return entry;
|
|
148
186
|
}
|
|
149
187
|
/**
|
|
150
188
|
* Patches an existing escalation row. `metadata` is merged, not replaced.
|
|
@@ -162,24 +200,35 @@ class EscalationClientService {
|
|
|
162
200
|
/**
|
|
163
201
|
* Atomically claims an escalation by UUID. Implicit model: `status` stays
|
|
164
202
|
* `'pending'`; claim is expressed via `assigned_to` + `assigned_until`.
|
|
203
|
+
* Returns `isExtension: true` when the same assignee re-claims a row they already hold.
|
|
165
204
|
*/
|
|
166
205
|
async claim(params) {
|
|
167
206
|
const hm = await this._engine(null, params.namespace);
|
|
168
|
-
|
|
207
|
+
const result = await hm.engine.store.claimEscalation(params);
|
|
208
|
+
if (result.ok === true)
|
|
209
|
+
this._emit('claimed', result.entry);
|
|
210
|
+
return result;
|
|
169
211
|
}
|
|
170
212
|
/**
|
|
171
213
|
* Atomically claims the highest-priority pending escalation whose `metadata`
|
|
172
|
-
* contains the given key/value.
|
|
173
|
-
*
|
|
214
|
+
* contains the given key/value. Optionally merges `metadata` into the claimed row
|
|
215
|
+
* in the same atomic UPDATE. Returns `isExtension: true` when the same assignee
|
|
216
|
+
* re-claims a row they already hold (extends the expiry).
|
|
174
217
|
*/
|
|
175
218
|
async claimByMetadata(params) {
|
|
176
219
|
const hm = await this._engine(null, params.namespace);
|
|
177
|
-
|
|
220
|
+
const result = await hm.engine.store.claimEscalationByMetadata(params);
|
|
221
|
+
if (result.ok === true)
|
|
222
|
+
this._emit('claimed', result.entry);
|
|
223
|
+
return result;
|
|
178
224
|
}
|
|
179
225
|
/** Releases a claimed escalation, returning it to available status. */
|
|
180
226
|
async release(params) {
|
|
181
227
|
const hm = await this._engine(null, params.namespace);
|
|
182
|
-
|
|
228
|
+
const result = await hm.engine.store.releaseEscalation(params);
|
|
229
|
+
if (result.ok === true)
|
|
230
|
+
this._emit('released', result.entry);
|
|
231
|
+
return result;
|
|
183
232
|
}
|
|
184
233
|
/**
|
|
185
234
|
* Reassigns the escalation to a different role, clearing any current claim
|
|
@@ -187,7 +236,10 @@ class EscalationClientService {
|
|
|
187
236
|
*/
|
|
188
237
|
async escalateToRole(params) {
|
|
189
238
|
const hm = await this._engine(null, params.namespace);
|
|
190
|
-
|
|
239
|
+
const entry = await hm.engine.store.escalateEscalationToRole(params);
|
|
240
|
+
if (entry)
|
|
241
|
+
this._emit('reassigned', entry);
|
|
242
|
+
return entry;
|
|
191
243
|
}
|
|
192
244
|
/**
|
|
193
245
|
* Cancels a pending escalation without delivering a signal. Terminal rows
|
|
@@ -195,17 +247,24 @@ class EscalationClientService {
|
|
|
195
247
|
*/
|
|
196
248
|
async cancel(id, namespace) {
|
|
197
249
|
const hm = await this._engine(null, namespace);
|
|
198
|
-
|
|
250
|
+
const result = await hm.engine.store.cancelEscalation(id, namespace);
|
|
251
|
+
if (result.ok === true)
|
|
252
|
+
this._emit('cancelled', result.entry);
|
|
253
|
+
return result;
|
|
199
254
|
}
|
|
200
255
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
256
|
+
* Resolves a pending escalation by UUID. Uses an explicit Postgres transaction
|
|
257
|
+
* with FOR UPDATE + WHERE guard: only one concurrent caller can commit the
|
|
258
|
+
* status change; the committed resolved row with its `signal_key` is the
|
|
259
|
+
* durable proof. Signal delivery is best-effort post-commit — the resolved
|
|
260
|
+
* row is the recovery record for any missed delivery. Returns the updated
|
|
261
|
+
* row as `entry` on success.
|
|
204
262
|
*/
|
|
205
263
|
async resolve(params, namespace) {
|
|
206
264
|
const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
|
|
207
265
|
const hm = await this._engine(null, ns);
|
|
208
|
-
const
|
|
266
|
+
const store = hm.engine.store;
|
|
267
|
+
const dbResult = await store.resolveEscalation({ id: params.id, resolverPayload: params.resolverPayload });
|
|
209
268
|
if (!dbResult.ok)
|
|
210
269
|
return dbResult;
|
|
211
270
|
if (dbResult.signalKey) {
|
|
@@ -214,16 +273,18 @@ class EscalationClientService {
|
|
|
214
273
|
data: params.resolverPayload ?? {},
|
|
215
274
|
});
|
|
216
275
|
}
|
|
217
|
-
|
|
276
|
+
this._emit('resolved', dbResult.entry);
|
|
277
|
+
return { ok: true, entry: dbResult.entry };
|
|
218
278
|
}
|
|
219
279
|
/**
|
|
220
280
|
* Resolves the highest-priority matching escalation by metadata filter,
|
|
221
|
-
* then delivers its signal.
|
|
281
|
+
* then delivers its signal. Same transaction + WHERE guard semantics as `resolve()`.
|
|
222
282
|
*/
|
|
223
283
|
async resolveByMetadata(params, namespace) {
|
|
224
284
|
const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
|
|
225
285
|
const hm = await this._engine(null, ns);
|
|
226
|
-
const
|
|
286
|
+
const store = hm.engine.store;
|
|
287
|
+
const dbResult = await store.resolveEscalationByMetadata({ key: params.key, value: params.value, resolverPayload: params.resolverPayload, roles: params.roles });
|
|
227
288
|
if (!dbResult.ok)
|
|
228
289
|
return dbResult;
|
|
229
290
|
if (dbResult.signalKey) {
|
|
@@ -232,7 +293,8 @@ class EscalationClientService {
|
|
|
232
293
|
data: params.resolverPayload ?? {},
|
|
233
294
|
});
|
|
234
295
|
}
|
|
235
|
-
|
|
296
|
+
this._emit('resolved', dbResult.entry);
|
|
297
|
+
return { ok: true, entry: dbResult.entry };
|
|
236
298
|
}
|
|
237
299
|
/**
|
|
238
300
|
* Full-fidelity migration: inserts an escalation row preserving the original
|
|
@@ -251,6 +313,62 @@ class EscalationClientService {
|
|
|
251
313
|
const hm = await this._engine(null, namespace);
|
|
252
314
|
return hm.engine.store.releaseExpiredEscalations(namespace);
|
|
253
315
|
}
|
|
316
|
+
// ─── Bulk operations ────────────────────────────────────────────────────────
|
|
317
|
+
/**
|
|
318
|
+
* Bulk-claims up to `ids.length` pending escalations in one statement.
|
|
319
|
+
* Returns `{ claimed, skipped }` — skipped rows are either already claimed
|
|
320
|
+
* by another assignee or non-existent. Implicit-claim semantics apply.
|
|
321
|
+
*/
|
|
322
|
+
async claimMany(params) {
|
|
323
|
+
const hm = await this._engine(null, params.namespace);
|
|
324
|
+
const { entries, skipped } = await hm.engine.store.claimManyEscalations(params);
|
|
325
|
+
this._emitMany('claimed', entries);
|
|
326
|
+
return { claimed: entries.length, skipped };
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Bulk-reassigns pending escalations to a new role, clearing any current claim.
|
|
330
|
+
* Returns the count of rows updated.
|
|
331
|
+
*/
|
|
332
|
+
async escalateManyToRole(params) {
|
|
333
|
+
const hm = await this._engine(null, params.namespace);
|
|
334
|
+
return hm.engine.store.escalateManyEscalationsToRole(params);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Bulk-updates priority for pending escalations. Returns the count of rows updated.
|
|
338
|
+
*/
|
|
339
|
+
async updateManyPriority(params) {
|
|
340
|
+
const hm = await this._engine(null, params.namespace);
|
|
341
|
+
return hm.engine.store.updateManyEscalationsPriority(params);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Bulk-resolves pending escalations by id-set. No signal delivery — intended
|
|
345
|
+
* for redirect-to-triage flows where no workflow is waiting. Returns the
|
|
346
|
+
* resolved rows.
|
|
347
|
+
*/
|
|
348
|
+
async resolveMany(params) {
|
|
349
|
+
const hm = await this._engine(null, params.namespace);
|
|
350
|
+
const entries = await hm.engine.store.resolveManyEscalations(params);
|
|
351
|
+
this._emitMany('resolved', entries);
|
|
352
|
+
return entries;
|
|
353
|
+
}
|
|
354
|
+
// ─── Aggregates ─────────────────────────────────────────────────────────────
|
|
355
|
+
/**
|
|
356
|
+
* Returns dashboard-ready escalation counts. `period` controls the window
|
|
357
|
+
* used for `created` and `resolved` counts (default `'24h'`). When `roles`
|
|
358
|
+
* is an empty array, all counts are zero (RBAC guard).
|
|
359
|
+
*/
|
|
360
|
+
async stats(params) {
|
|
361
|
+
const hm = await this._engine(null, params?.namespace);
|
|
362
|
+
return hm.engine.store.escalationStats(params ?? {});
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Returns the sorted list of distinct `type` values in the escalations table.
|
|
366
|
+
* Useful for populating filter dropdowns.
|
|
367
|
+
*/
|
|
368
|
+
async listDistinctTypes(namespace) {
|
|
369
|
+
const hm = await this._engine(null, namespace);
|
|
370
|
+
return hm.engine.store.listDistinctEscalationTypes(namespace);
|
|
371
|
+
}
|
|
254
372
|
static async shutdown() {
|
|
255
373
|
for (const [_, instance] of EscalationClientService.instances) {
|
|
256
374
|
(await instance).stop();
|
|
@@ -124,6 +124,8 @@ declare class HotMesh {
|
|
|
124
124
|
*/
|
|
125
125
|
workers: WorkerService[];
|
|
126
126
|
logger: ILogger;
|
|
127
|
+
/** @private — retained from init config for stop() lifecycle event */
|
|
128
|
+
private _eventsPublish?;
|
|
127
129
|
static disconnecting: boolean;
|
|
128
130
|
/**
|
|
129
131
|
* @private
|
|
@@ -144,6 +146,13 @@ declare class HotMesh {
|
|
|
144
146
|
* functions that consume messages from Postgres streams — they can
|
|
145
147
|
* run on the same process or on entirely separate servers.
|
|
146
148
|
*
|
|
149
|
+
* Pass `config.events` to receive lifecycle events from this engine:
|
|
150
|
+
* - `system.engine.{appId}.started` — fires when init completes.
|
|
151
|
+
* - `system.engine.{appId}.deployed` — fires after schema deploy.
|
|
152
|
+
* - `system.engine.{appId}.stopped` — fires when `stop()` is called.
|
|
153
|
+
* - `system.escalation.{id}.created` — fires from the hook Leg1 path
|
|
154
|
+
* (YAML `escalation:` block) when the escalation row commits.
|
|
155
|
+
*
|
|
147
156
|
* @param config - Engine connection, worker definitions, app ID, and options.
|
|
148
157
|
* @returns A running HotMesh instance joined to the quorum.
|
|
149
158
|
*/
|