@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 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';
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.22.2",
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
- await transaction.exec();
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 second connection.
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 _connection?;
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. Returns `isExtension: true` when the same
92
- * assignee re-claims a row they already hold (extends the expiry).
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
- * Signal-first resolve: marks the escalation resolved **and** delivers the
109
- * signal to the waiting workflow in a single held transaction. If signal
110
- * delivery fails, the transaction rolls back `committed: false`.
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
- return hm.engine.store.createEscalation(params);
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
- return hm.engine.store.claimEscalation(params);
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. Returns `isExtension: true` when the same
173
- * assignee re-claims a row they already hold (extends the expiry).
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
- return hm.engine.store.claimEscalationByMetadata(params);
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
- return hm.engine.store.releaseEscalation(params);
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
- return hm.engine.store.escalateEscalationToRole(params);
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
- return hm.engine.store.cancelEscalation(id, namespace);
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
- * Signal-first resolve: marks the escalation resolved **and** delivers the
202
- * signal to the waiting workflow in a single held transaction. If signal
203
- * delivery fails, the transaction rolls back `committed: false`.
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 dbResult = await hm.engine.store.resolveEscalation({ id: params.id, resolverPayload: params.resolverPayload });
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
- return { ok: true };
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 dbResult = await hm.engine.store.resolveEscalationByMetadata({ key: params.key, value: params.value, resolverPayload: params.resolverPayload, roles: params.roles });
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
- return { ok: true };
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
  */