@hotmeshio/hotmesh 0.22.3 → 0.22.5
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 +2 -1
- package/build/services/activities/hook.js +34 -5
- package/build/services/durable/client.d.ts +17 -0
- package/build/services/durable/client.js +15 -1
- package/build/services/durable/worker.d.ts +10 -0
- package/build/services/durable/worker.js +36 -0
- package/build/services/escalations/client.d.ts +19 -0
- package/build/services/escalations/client.js +69 -9
- 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 +12 -0
- package/build/services/store/providers/postgres/postgres.d.ts +3 -1
- package/build/services/store/providers/postgres/postgres.js +11 -8
- package/build/types/durable.d.ts +11 -0
- package/build/types/hmsh_escalations.d.ts +1 -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 +2 -1
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.5",
|
|
4
4
|
"description": "Durable Workflow",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"types": "./build/index.d.ts",
|
|
@@ -89,6 +89,7 @@
|
|
|
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
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",
|
|
92
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",
|
|
93
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",
|
|
94
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
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { HotMesh } from '../hotmesh';
|
|
2
2
|
import { ClientConfig, ClientWorkflow, Connection, WorkflowOptions } from '../../types/durable';
|
|
3
3
|
import { EscalationClientService } from '../escalations/client';
|
|
4
|
+
import { EventsConfig } from '../../types/system_events';
|
|
4
5
|
/**
|
|
5
6
|
* Workflow client. Starts workflows, sends signals, and reads results.
|
|
6
7
|
*
|
|
8
|
+
* Pass `config.events` to receive system-event notifications from all
|
|
9
|
+
* escalation operations performed through this client. Events fire
|
|
10
|
+
* post-commit, from this process only — no fanout to other containers.
|
|
11
|
+
* See `EventsConfig` and `SystemEvent` in `types/system_events` for the full ontology.
|
|
12
|
+
*
|
|
7
13
|
* @example
|
|
8
14
|
* ```typescript
|
|
9
15
|
* import { Durable } from '@hotmeshio/hotmesh';
|
|
@@ -14,6 +20,13 @@ import { EscalationClientService } from '../escalations/client';
|
|
|
14
20
|
* class: Postgres,
|
|
15
21
|
* options: { connectionString: 'postgresql://usr:pwd@localhost:5432/db' },
|
|
16
22
|
* },
|
|
23
|
+
* // optional — wire lifecycle events
|
|
24
|
+
* events: {
|
|
25
|
+
* publish: (event) => {
|
|
26
|
+
* // event.type follows system.escalation.{id}.{verb}
|
|
27
|
+
* myEventBus.emit(event.type, event.data);
|
|
28
|
+
* },
|
|
29
|
+
* },
|
|
17
30
|
* });
|
|
18
31
|
*
|
|
19
32
|
* // Start a workflow and await its result
|
|
@@ -41,6 +54,10 @@ export declare class ClientService {
|
|
|
41
54
|
* @private
|
|
42
55
|
*/
|
|
43
56
|
options: WorkflowOptions;
|
|
57
|
+
/**
|
|
58
|
+
* @private
|
|
59
|
+
*/
|
|
60
|
+
events?: EventsConfig;
|
|
44
61
|
/**
|
|
45
62
|
* @private
|
|
46
63
|
*/
|
|
@@ -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
|
|
@@ -73,6 +85,7 @@ class ClientService {
|
|
|
73
85
|
readonly,
|
|
74
86
|
connection: this.connection,
|
|
75
87
|
},
|
|
88
|
+
events: this.events,
|
|
76
89
|
});
|
|
77
90
|
//synchronously cache the promise (before awaiting)
|
|
78
91
|
ClientService.instances.set(connectionNS, hotMeshClient);
|
|
@@ -286,9 +299,10 @@ class ClientService {
|
|
|
286
299
|
},
|
|
287
300
|
};
|
|
288
301
|
this.connection = config.connection;
|
|
289
|
-
|
|
302
|
+
this.events = config.events;
|
|
290
303
|
this.escalations = new client_1.EscalationClientService({
|
|
291
304
|
getHotMeshClient: this.getHotMeshClient.bind(this),
|
|
305
|
+
events: config.events,
|
|
292
306
|
});
|
|
293
307
|
}
|
|
294
308
|
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
|
*/
|
|
@@ -309,6 +309,7 @@ class WorkerService {
|
|
|
309
309
|
appId: targetNamespace,
|
|
310
310
|
engine: { connection: config.connection },
|
|
311
311
|
workers: [workerEntry],
|
|
312
|
+
events: config.events,
|
|
312
313
|
});
|
|
313
314
|
WorkerService.instances.set(targetTopic, hotMeshWorker);
|
|
314
315
|
return hotMeshWorker;
|
|
@@ -478,6 +479,21 @@ class WorkerService {
|
|
|
478
479
|
worker.workflowRunner = await worker.initWorkflowWorker(config, taskQueue, workflowFunctionName, workflowTopic, workflowFunction);
|
|
479
480
|
search_1.Search.configureSearchIndex(worker.workflowRunner, config.search);
|
|
480
481
|
await WorkerService.activateWorkflow(worker.workflowRunner);
|
|
482
|
+
// Fire system.worker.{taskQueue}.started post-init (best-effort).
|
|
483
|
+
if (config.events?.publish) {
|
|
484
|
+
worker._eventsPublish = config.events.publish;
|
|
485
|
+
worker._eventsTaskQueue = taskQueue;
|
|
486
|
+
worker._eventsAppId = targetNamespace;
|
|
487
|
+
const ts = new Date().toISOString();
|
|
488
|
+
void Promise.resolve(config.events.publish({
|
|
489
|
+
event_id: `${taskQueue}:started:${ts}`,
|
|
490
|
+
type: `system.worker.${taskQueue}.started`,
|
|
491
|
+
ts,
|
|
492
|
+
namespace: targetNamespace,
|
|
493
|
+
app_id: targetNamespace,
|
|
494
|
+
data: { taskQueue, appId: targetNamespace },
|
|
495
|
+
})).catch(() => { });
|
|
496
|
+
}
|
|
481
497
|
return worker;
|
|
482
498
|
}
|
|
483
499
|
/**
|
|
@@ -502,6 +518,24 @@ class WorkerService {
|
|
|
502
518
|
async run() {
|
|
503
519
|
this.workflowRunner.engine.logger.info('durable-worker-running');
|
|
504
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Stops the worker's HotMesh instances and fires `system.worker.{taskQueue}.stopped`.
|
|
523
|
+
*/
|
|
524
|
+
stop() {
|
|
525
|
+
this.workflowRunner?.stop();
|
|
526
|
+
this.activityRunner?.stop();
|
|
527
|
+
if (this._eventsPublish && this._eventsTaskQueue) {
|
|
528
|
+
const ts = new Date().toISOString();
|
|
529
|
+
void Promise.resolve(this._eventsPublish({
|
|
530
|
+
event_id: `${this._eventsTaskQueue}:stopped:${ts}`,
|
|
531
|
+
type: `system.worker.${this._eventsTaskQueue}.stopped`,
|
|
532
|
+
ts,
|
|
533
|
+
namespace: this._eventsAppId ?? '',
|
|
534
|
+
app_id: this._eventsAppId ?? '',
|
|
535
|
+
data: { taskQueue: this._eventsTaskQueue, appId: this._eventsAppId },
|
|
536
|
+
})).catch(() => { });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
505
539
|
/**
|
|
506
540
|
* @private
|
|
507
541
|
*/
|
|
@@ -531,6 +565,7 @@ class WorkerService {
|
|
|
531
565
|
appId: targetNamespace,
|
|
532
566
|
engine: { connection: providerConfig },
|
|
533
567
|
workers: [workerEntry],
|
|
568
|
+
events: config.events,
|
|
534
569
|
});
|
|
535
570
|
WorkerService.instances.set(targetTopic, hotMeshWorker);
|
|
536
571
|
return hotMeshWorker;
|
|
@@ -660,6 +695,7 @@ class WorkerService {
|
|
|
660
695
|
appId: config.namespace ?? factory_1.APP_ID,
|
|
661
696
|
engine: { connection: providerConfig },
|
|
662
697
|
workers: [workerEntry],
|
|
698
|
+
events: config.events,
|
|
663
699
|
});
|
|
664
700
|
WorkerService.instances.set(targetTopic, hotMeshWorker);
|
|
665
701
|
return hotMeshWorker;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HotMesh } from '../hotmesh';
|
|
2
|
+
import { EventsConfig } from '../../types/system_events';
|
|
2
3
|
import { Connection } from '../../types/durable';
|
|
3
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';
|
|
4
5
|
export type GetHotMeshFn = (topic: string | null, namespace?: string) => Promise<HotMesh>;
|
|
@@ -10,6 +11,12 @@ export interface EscalationClientConfig {
|
|
|
10
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,8 +55,20 @@ export interface EscalationClientConfig {
|
|
|
48
55
|
*/
|
|
49
56
|
export declare class EscalationClientService {
|
|
50
57
|
private readonly _engine;
|
|
58
|
+
private readonly _events?;
|
|
51
59
|
static instances: Map<string, HotMesh | Promise<HotMesh>>;
|
|
52
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;
|
|
53
72
|
private _makeEngineFactory;
|
|
54
73
|
private _hashConnection;
|
|
55
74
|
private _deliverEscalationSignal;
|
|
@@ -44,7 +44,6 @@ 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) {
|
|
@@ -53,6 +52,43 @@ class EscalationClientService {
|
|
|
53
52
|
else {
|
|
54
53
|
throw new Error('EscalationClient requires either `connection` or `getHotMeshClient`');
|
|
55
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);
|
|
56
92
|
}
|
|
57
93
|
_makeEngineFactory(connection) {
|
|
58
94
|
return async (topic, namespace) => {
|
|
@@ -143,7 +179,10 @@ class EscalationClientService {
|
|
|
143
179
|
*/
|
|
144
180
|
async create(params) {
|
|
145
181
|
const hm = await this._engine(null, params.namespace);
|
|
146
|
-
|
|
182
|
+
const entry = await hm.engine.store.createEscalation(params);
|
|
183
|
+
if (entry)
|
|
184
|
+
this._emit('created', entry);
|
|
185
|
+
return entry;
|
|
147
186
|
}
|
|
148
187
|
/**
|
|
149
188
|
* Patches an existing escalation row. `metadata` is merged, not replaced.
|
|
@@ -165,7 +204,10 @@ class EscalationClientService {
|
|
|
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`
|
|
@@ -175,12 +217,18 @@ class EscalationClientService {
|
|
|
175
217
|
*/
|
|
176
218
|
async claimByMetadata(params) {
|
|
177
219
|
const hm = await this._engine(null, params.namespace);
|
|
178
|
-
|
|
220
|
+
const result = await hm.engine.store.claimEscalationByMetadata(params);
|
|
221
|
+
if (result.ok === true)
|
|
222
|
+
this._emit('claimed', result.entry);
|
|
223
|
+
return result;
|
|
179
224
|
}
|
|
180
225
|
/** Releases a claimed escalation, returning it to available status. */
|
|
181
226
|
async release(params) {
|
|
182
227
|
const hm = await this._engine(null, params.namespace);
|
|
183
|
-
|
|
228
|
+
const result = await hm.engine.store.releaseEscalation(params);
|
|
229
|
+
if (result.ok === true)
|
|
230
|
+
this._emit('released', result.entry);
|
|
231
|
+
return result;
|
|
184
232
|
}
|
|
185
233
|
/**
|
|
186
234
|
* Reassigns the escalation to a different role, clearing any current claim
|
|
@@ -188,7 +236,10 @@ class EscalationClientService {
|
|
|
188
236
|
*/
|
|
189
237
|
async escalateToRole(params) {
|
|
190
238
|
const hm = await this._engine(null, params.namespace);
|
|
191
|
-
|
|
239
|
+
const entry = await hm.engine.store.escalateEscalationToRole(params);
|
|
240
|
+
if (entry)
|
|
241
|
+
this._emit('reassigned', entry);
|
|
242
|
+
return entry;
|
|
192
243
|
}
|
|
193
244
|
/**
|
|
194
245
|
* Cancels a pending escalation without delivering a signal. Terminal rows
|
|
@@ -196,7 +247,10 @@ class EscalationClientService {
|
|
|
196
247
|
*/
|
|
197
248
|
async cancel(id, namespace) {
|
|
198
249
|
const hm = await this._engine(null, namespace);
|
|
199
|
-
|
|
250
|
+
const result = await hm.engine.store.cancelEscalation(id, namespace);
|
|
251
|
+
if (result.ok === true)
|
|
252
|
+
this._emit('cancelled', result.entry);
|
|
253
|
+
return result;
|
|
200
254
|
}
|
|
201
255
|
/**
|
|
202
256
|
* Resolves a pending escalation by UUID. Uses an explicit Postgres transaction
|
|
@@ -219,6 +273,7 @@ class EscalationClientService {
|
|
|
219
273
|
data: params.resolverPayload ?? {},
|
|
220
274
|
});
|
|
221
275
|
}
|
|
276
|
+
this._emit('resolved', dbResult.entry);
|
|
222
277
|
return { ok: true, entry: dbResult.entry };
|
|
223
278
|
}
|
|
224
279
|
/**
|
|
@@ -238,6 +293,7 @@ class EscalationClientService {
|
|
|
238
293
|
data: params.resolverPayload ?? {},
|
|
239
294
|
});
|
|
240
295
|
}
|
|
296
|
+
this._emit('resolved', dbResult.entry);
|
|
241
297
|
return { ok: true, entry: dbResult.entry };
|
|
242
298
|
}
|
|
243
299
|
/**
|
|
@@ -265,7 +321,9 @@ class EscalationClientService {
|
|
|
265
321
|
*/
|
|
266
322
|
async claimMany(params) {
|
|
267
323
|
const hm = await this._engine(null, params.namespace);
|
|
268
|
-
|
|
324
|
+
const { entries, skipped } = await hm.engine.store.claimManyEscalations(params);
|
|
325
|
+
this._emitMany('claimed', entries);
|
|
326
|
+
return { claimed: entries.length, skipped };
|
|
269
327
|
}
|
|
270
328
|
/**
|
|
271
329
|
* Bulk-reassigns pending escalations to a new role, clearing any current claim.
|
|
@@ -289,7 +347,9 @@ class EscalationClientService {
|
|
|
289
347
|
*/
|
|
290
348
|
async resolveMany(params) {
|
|
291
349
|
const hm = await this._engine(null, params.namespace);
|
|
292
|
-
|
|
350
|
+
const entries = await hm.engine.store.resolveManyEscalations(params);
|
|
351
|
+
this._emitMany('resolved', entries);
|
|
352
|
+
return entries;
|
|
293
353
|
}
|
|
294
354
|
// ─── Aggregates ─────────────────────────────────────────────────────────────
|
|
295
355
|
/**
|
|
@@ -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
|
*/
|
|
@@ -170,6 +170,13 @@ class HotMesh {
|
|
|
170
170
|
* functions that consume messages from Postgres streams — they can
|
|
171
171
|
* run on the same process or on entirely separate servers.
|
|
172
172
|
*
|
|
173
|
+
* Pass `config.events` to receive lifecycle events from this engine:
|
|
174
|
+
* - `system.engine.{appId}.started` — fires when init completes.
|
|
175
|
+
* - `system.engine.{appId}.deployed` — fires after schema deploy.
|
|
176
|
+
* - `system.engine.{appId}.stopped` — fires when `stop()` is called.
|
|
177
|
+
* - `system.escalation.{id}.created` — fires from the hook Leg1 path
|
|
178
|
+
* (YAML `escalation:` block) when the escalation row commits.
|
|
179
|
+
*
|
|
173
180
|
* @param config - Engine connection, worker definitions, app ID, and options.
|
|
174
181
|
* @returns A running HotMesh instance joined to the quorum.
|
|
175
182
|
*/
|
|
@@ -196,6 +203,24 @@ class HotMesh {
|
|
|
196
203
|
});
|
|
197
204
|
}
|
|
198
205
|
await Init.doWork(instance, config, instance.logger);
|
|
206
|
+
// Thread events.publish to the store (used by the hook Leg1 path and deploy).
|
|
207
|
+
if (config.events?.publish && instance.engine?.store) {
|
|
208
|
+
instance.engine.store.eventsPublish = config.events.publish;
|
|
209
|
+
}
|
|
210
|
+
// Retain for stop() lifecycle event.
|
|
211
|
+
if (config.events?.publish) {
|
|
212
|
+
instance._eventsPublish = config.events.publish;
|
|
213
|
+
const ts = new Date().toISOString();
|
|
214
|
+
const event = {
|
|
215
|
+
event_id: `${config.appId}:started:${ts}`,
|
|
216
|
+
type: `system.engine.${config.appId}.started`,
|
|
217
|
+
ts,
|
|
218
|
+
namespace: instance.namespace,
|
|
219
|
+
app_id: config.appId,
|
|
220
|
+
data: { appId: config.appId, guid: instance.guid },
|
|
221
|
+
};
|
|
222
|
+
void Promise.resolve(config.events.publish(event)).catch(() => { });
|
|
223
|
+
}
|
|
199
224
|
return instance;
|
|
200
225
|
}
|
|
201
226
|
/**
|
|
@@ -489,6 +514,18 @@ class HotMesh {
|
|
|
489
514
|
this.workers?.forEach((worker) => {
|
|
490
515
|
worker.stop();
|
|
491
516
|
});
|
|
517
|
+
if (this._eventsPublish) {
|
|
518
|
+
const ts = new Date().toISOString();
|
|
519
|
+
const event = {
|
|
520
|
+
event_id: `${this.appId}:stopped:${ts}`,
|
|
521
|
+
type: `system.engine.${this.appId}.stopped`,
|
|
522
|
+
ts,
|
|
523
|
+
namespace: this.namespace,
|
|
524
|
+
app_id: this.appId,
|
|
525
|
+
data: { appId: this.appId, guid: this.guid },
|
|
526
|
+
};
|
|
527
|
+
void Promise.resolve(this._eventsPublish(event)).catch(() => { });
|
|
528
|
+
}
|
|
492
529
|
}
|
|
493
530
|
/**
|
|
494
531
|
* @private
|
|
@@ -71,6 +71,18 @@ const KVTables = (context) => ({
|
|
|
71
71
|
await client.release();
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
// Fire system.engine.{appId}.deployed post-deploy (best-effort).
|
|
75
|
+
if (context.eventsPublish) {
|
|
76
|
+
const ts = new Date().toISOString();
|
|
77
|
+
void Promise.resolve(context.eventsPublish({
|
|
78
|
+
event_id: `${appName}:deployed:${ts}`,
|
|
79
|
+
type: `system.engine.${appName}.deployed`,
|
|
80
|
+
ts,
|
|
81
|
+
namespace: appName,
|
|
82
|
+
app_id: appName,
|
|
83
|
+
data: { appId: appName },
|
|
84
|
+
})).catch(() => { });
|
|
85
|
+
}
|
|
74
86
|
},
|
|
75
87
|
getAdvisoryLockId(appName) {
|
|
76
88
|
return this.hashStringToInt(appName);
|
|
@@ -19,6 +19,8 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
19
19
|
pgClient: PostgresClientType;
|
|
20
20
|
kvTables: ReturnType<typeof KVTables>;
|
|
21
21
|
isScout: boolean;
|
|
22
|
+
/** Set by HotMesh.init() when `events.publish` is configured. Used by hook.ts Leg1 path. */
|
|
23
|
+
eventsPublish?: (event: import('../../../../types/system_events').SystemEvent) => void | Promise<void>;
|
|
22
24
|
transact(): ProviderTransaction;
|
|
23
25
|
constructor(storeClient: ProviderClient);
|
|
24
26
|
init(namespace: string, appId: string, logger: ILogger, guid?: string, role?: string): Promise<HotMeshApps>;
|
|
@@ -265,7 +267,7 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
265
267
|
updateEscalation(params: import('../../../../types/hmsh_escalations').UpdateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
|
|
266
268
|
appendEscalationMilestones(params: import('../../../../types/hmsh_escalations').AppendMilestonesParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
|
|
267
269
|
claimManyEscalations(params: import('../../../../types/hmsh_escalations').ClaimManyParams): Promise<{
|
|
268
|
-
|
|
270
|
+
entries: import('../../../../types/hmsh_escalations').EscalationEntry[];
|
|
269
271
|
skipped: number;
|
|
270
272
|
}>;
|
|
271
273
|
escalateManyEscalationsToRole(params: import('../../../../types/hmsh_escalations').EscalateManyToRoleParams): Promise<number>;
|
|
@@ -1439,7 +1439,7 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1439
1439
|
* idempotent re-runs after a crash.
|
|
1440
1440
|
*/
|
|
1441
1441
|
addEscalationToTransaction(params, transaction) {
|
|
1442
|
-
transaction.addCommand(this._escalationInsertSql, this._escalationInsertParams(params), '
|
|
1442
|
+
transaction.addCommand(this._escalationInsertSql + ' RETURNING *', this._escalationInsertParams(params), 'object');
|
|
1443
1443
|
}
|
|
1444
1444
|
/**
|
|
1445
1445
|
* Full-fidelity INSERT for data migration. Preserves the original `id` (UUID),
|
|
@@ -1827,22 +1827,24 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1827
1827
|
FROM target
|
|
1828
1828
|
WHERE public.hmsh_escalations.id = target.id
|
|
1829
1829
|
AND target.status = 'pending'
|
|
1830
|
-
RETURNING public.hmsh_escalations
|
|
1830
|
+
RETURNING public.hmsh_escalations.*
|
|
1831
1831
|
)
|
|
1832
1832
|
SELECT t.id, t.status AS prior_status,
|
|
1833
1833
|
CASE
|
|
1834
1834
|
WHEN c.id IS NOT NULL THEN 'cancelled'
|
|
1835
1835
|
WHEN t.id IS NULL THEN 'not-found'
|
|
1836
1836
|
ELSE 'already-terminal'
|
|
1837
|
-
END AS outcome
|
|
1837
|
+
END AS outcome,
|
|
1838
|
+
row_to_json(c.*) AS entry_json
|
|
1838
1839
|
FROM (SELECT * FROM target) t
|
|
1839
|
-
FULL OUTER JOIN (SELECT
|
|
1840
|
+
FULL OUTER JOIN (SELECT * FROM cancelled) c ON c.id = t.id
|
|
1840
1841
|
`, namespace ? [id, namespace] : [id]);
|
|
1841
1842
|
if (!result.rows[0] || result.rows[0].outcome === 'not-found')
|
|
1842
1843
|
return { ok: false, reason: 'not-found' };
|
|
1843
1844
|
if (result.rows[0].outcome === 'already-terminal')
|
|
1844
1845
|
return { ok: false, reason: 'already-terminal' };
|
|
1845
|
-
|
|
1846
|
+
const entry = result.rows[0].entry_json;
|
|
1847
|
+
return { ok: true, entry };
|
|
1846
1848
|
}
|
|
1847
1849
|
async escalateEscalationToRole(params) {
|
|
1848
1850
|
const { id, targetRole, namespace } = params;
|
|
@@ -1947,9 +1949,10 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1947
1949
|
WHERE id = ANY($3::uuid[])
|
|
1948
1950
|
${namespace ? 'AND namespace = $4' : ''}
|
|
1949
1951
|
AND status = 'pending'
|
|
1950
|
-
AND (assigned_to IS NULL OR assigned_until IS NULL OR assigned_until <= NOW() OR assigned_to = $1)
|
|
1951
|
-
|
|
1952
|
-
|
|
1952
|
+
AND (assigned_to IS NULL OR assigned_until IS NULL OR assigned_until <= NOW() OR assigned_to = $1)
|
|
1953
|
+
RETURNING *`, namespace ? [assignee, durationMinutes, ids, namespace] : [assignee, durationMinutes, ids]);
|
|
1954
|
+
const entries = result.rows;
|
|
1955
|
+
return { entries, skipped: ids.length - entries.length };
|
|
1953
1956
|
}
|
|
1954
1957
|
async escalateManyEscalationsToRole(params) {
|
|
1955
1958
|
const { ids, namespace, targetRole } = params;
|
package/build/types/durable.d.ts
CHANGED
|
@@ -395,6 +395,12 @@ type WorkflowDataType = {
|
|
|
395
395
|
type Connection = ProviderConfig | ProvidersConfig;
|
|
396
396
|
type ClientConfig = {
|
|
397
397
|
connection: Connection;
|
|
398
|
+
/**
|
|
399
|
+
* Optional system-event sink. When set, `client.escalations.*` operations
|
|
400
|
+
* call `events.publish` post-commit from the invoking process. Wires the
|
|
401
|
+
* same hook as `HotMeshConfig.events` for direct-client callers.
|
|
402
|
+
*/
|
|
403
|
+
events?: import('./system_events').EventsConfig;
|
|
398
404
|
};
|
|
399
405
|
type Registry = {
|
|
400
406
|
[key: string]: Function;
|
|
@@ -442,6 +448,11 @@ type WorkerConfig = {
|
|
|
442
448
|
user: string;
|
|
443
449
|
password: string;
|
|
444
450
|
};
|
|
451
|
+
/**
|
|
452
|
+
* Optional system-event sink. When set, the worker fires `events.publish`
|
|
453
|
+
* on `system.worker.{taskQueue}.started` and `system.worker.{taskQueue}.stopped`.
|
|
454
|
+
*/
|
|
455
|
+
events?: import('./system_events').EventsConfig;
|
|
445
456
|
};
|
|
446
457
|
type FindWhereQuery = {
|
|
447
458
|
field: string;
|
package/build/types/hotmesh.d.ts
CHANGED
|
@@ -309,6 +309,23 @@ type HotMeshConfig = {
|
|
|
309
309
|
taskQueue?: string;
|
|
310
310
|
engine?: HotMeshEngine;
|
|
311
311
|
workers?: HotMeshWorker[];
|
|
312
|
+
/**
|
|
313
|
+
* Optional system-event sink. When provided, the engine calls
|
|
314
|
+
* `events.publish` post-commit for each durable transition it performs
|
|
315
|
+
* (escalation lifecycle, engine start/stop, deploy). Fire-and-forget.
|
|
316
|
+
*
|
|
317
|
+
* @example
|
|
318
|
+
* ```typescript
|
|
319
|
+
* const hotMesh = await HotMesh.init({
|
|
320
|
+
* appId: 'myapp',
|
|
321
|
+
* engine: { connection: { class: Postgres, options: { ... } } },
|
|
322
|
+
* events: {
|
|
323
|
+
* publish: (e) => nats.publish(e.type, JSON.stringify(e)),
|
|
324
|
+
* },
|
|
325
|
+
* });
|
|
326
|
+
* ```
|
|
327
|
+
*/
|
|
328
|
+
events?: import('./system_events').EventsConfig;
|
|
312
329
|
};
|
|
313
330
|
type HotMeshGraph = {
|
|
314
331
|
/**
|
package/build/types/index.d.ts
CHANGED
|
@@ -26,3 +26,4 @@ export { context, Context, Counter, Meter, metrics, propagation, SpanContext, Sp
|
|
|
26
26
|
export { WorkListTaskType } from './task';
|
|
27
27
|
export { TransitionMatch, TransitionRule, Transitions } from './transition';
|
|
28
28
|
export { ConditionQueueConfig, EscalationEntry, ClaimEscalationResult, ClaimByMetadataResult, ReleaseEscalationResult, ResolveEscalationResult, CancelEscalationResult, ListEscalationsParams, CreateEscalationParams, UpdateEscalationParams, AppendMilestonesParams, ClaimEscalationParams, ClaimByMetadataParams, ReleaseEscalationParams, ResolveEscalationParams, ResolveByMetadataParams, EscalateToRoleParams, MigrateEscalationParams, } from './hmsh_escalations';
|
|
29
|
+
export { EscalationVerb, EngineVerb, WorkerVerb, SystemEvent, EventsConfig } from './system_events';
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-event emission surface for HotMesh lifecycle transitions.
|
|
3
|
+
*
|
|
4
|
+
* The performing actor — the one engine/SDK call that commits a durable
|
|
5
|
+
* transition — fires `EventsConfig.publish` exactly once, inline,
|
|
6
|
+
* post-commit. The hook is fire-and-forget; the SDK never awaits it.
|
|
7
|
+
*
|
|
8
|
+
* ## Ontology
|
|
9
|
+
*
|
|
10
|
+
* Every event has a canonical `type` string and a `data` payload.
|
|
11
|
+
* Consumers pattern-match on `type` and cherry-pick fields from `data`.
|
|
12
|
+
*
|
|
13
|
+
* ### Escalation lifecycle (`system.escalation.{id}.{verb}`)
|
|
14
|
+
*
|
|
15
|
+
* | verb | trigger | `data` shape |
|
|
16
|
+
* |-------------|----------------------------------------------------------|---------------------|
|
|
17
|
+
* | `created` | `client.create()` or hook Leg1 INSERT | full `EscalationEntry` row |
|
|
18
|
+
* | `claimed` | `client.claim()` / `client.claimByMetadata()` | full `EscalationEntry` row |
|
|
19
|
+
* | `released` | `client.release()` | full `EscalationEntry` row |
|
|
20
|
+
* | `reassigned`| `client.escalateToRole()` / role change | full `EscalationEntry` row |
|
|
21
|
+
* | `resolved` | `client.resolve()` / `client.resolveByMetadata()` | full `EscalationEntry` row |
|
|
22
|
+
* | `cancelled` | `client.cancel()` | full `EscalationEntry` row |
|
|
23
|
+
*
|
|
24
|
+
* ### Engine lifecycle (`system.engine.{appId}.{verb}`)
|
|
25
|
+
*
|
|
26
|
+
* | verb | trigger | `data` shape |
|
|
27
|
+
* |------------|----------------------------------|----------------------------------|
|
|
28
|
+
* | `started` | `HotMesh.init()` completes | `{ appId: string, guid: string }` |
|
|
29
|
+
* | `stopped` | `hotMesh.stop()` called | `{ appId: string, guid: string }` |
|
|
30
|
+
* | `deployed` | `kvTables.deploy()` completes | `{ appId: string }` |
|
|
31
|
+
*
|
|
32
|
+
* ### Worker lifecycle (`system.worker.{taskQueue}.{verb}`)
|
|
33
|
+
*
|
|
34
|
+
* | verb | trigger | `data` shape |
|
|
35
|
+
* |------------|----------------------------------|----------------------------------------------|
|
|
36
|
+
* | `started` | `Durable.Worker.create()` ready | `{ taskQueue: string, appId: string }` |
|
|
37
|
+
* | `stopped` | `worker.stop()` called | `{ taskQueue: string, appId: string }` |
|
|
38
|
+
*
|
|
39
|
+
* ## Registration — three construction sites
|
|
40
|
+
*
|
|
41
|
+
* **Site 1 — YAML DAG / hook Leg1 (escalation `created` + engine events):**
|
|
42
|
+
* ```typescript
|
|
43
|
+
* import { HotMesh } from '@hotmeshio/hotmesh';
|
|
44
|
+
* const hm = await HotMesh.init({
|
|
45
|
+
* appId: 'myapp',
|
|
46
|
+
* engine: { connection },
|
|
47
|
+
* events: { publish: (e) => bus.emit(e.type, e) },
|
|
48
|
+
* });
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* **Site 2 — Standalone `EscalationClientService` (all 6 verbs):**
|
|
52
|
+
* ```typescript
|
|
53
|
+
* import { Escalations } from '@hotmeshio/hotmesh';
|
|
54
|
+
* const client = new Escalations.Client({
|
|
55
|
+
* connection,
|
|
56
|
+
* events: { publish: (e) => bus.emit(e.type, e) },
|
|
57
|
+
* });
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* **Site 3 — `Durable.Client` (all 6 verbs via `.escalations`):**
|
|
61
|
+
* ```typescript
|
|
62
|
+
* import { Durable } from '@hotmeshio/hotmesh';
|
|
63
|
+
* const client = new Durable.Client({
|
|
64
|
+
* connection,
|
|
65
|
+
* events: { publish: (e) => bus.emit(e.type, e) },
|
|
66
|
+
* });
|
|
67
|
+
* // client.escalations.* operations now emit lifecycle events
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* ## Event ID format
|
|
71
|
+
*
|
|
72
|
+
* `event_id` is collision-proof across recurrences:
|
|
73
|
+
* - Escalation: `${id}:${verb}:${updated_at_iso}` — claim→release→reclaim
|
|
74
|
+
* produces distinct IDs because `updated_at` changes on each transition.
|
|
75
|
+
* - Engine / worker: `${app_id_or_queue}:${verb}:${ts}`.
|
|
76
|
+
*
|
|
77
|
+
* ## Fire-and-forget contract
|
|
78
|
+
*
|
|
79
|
+
* The SDK wraps every `publish` call in
|
|
80
|
+
* `void Promise.resolve(publish(event)).catch(() => {})`.
|
|
81
|
+
* A slow or throwing `publish` implementation never blocks or fails the
|
|
82
|
+
* committed operation. Use an in-process buffer or async queue if you need
|
|
83
|
+
* back-pressure.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* import { Durable } from '@hotmeshio/hotmesh';
|
|
88
|
+
* import { Client as Postgres } from 'pg';
|
|
89
|
+
*
|
|
90
|
+
* const client = new Durable.Client({
|
|
91
|
+
* connection: { class: Postgres, options: { connectionString: process.env.DATABASE_URL } },
|
|
92
|
+
* events: {
|
|
93
|
+
* publish: (event) => {
|
|
94
|
+
* if (event.type.endsWith('.created')) {
|
|
95
|
+
* // new escalation — route to the right team
|
|
96
|
+
* dispatch(event.data as EscalationEntry);
|
|
97
|
+
* }
|
|
98
|
+
* },
|
|
99
|
+
* },
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
/** Verbs for escalation lifecycle transitions. */
|
|
104
|
+
export type EscalationVerb = 'created' | 'claimed' | 'released' | 'reassigned' | 'resolved' | 'cancelled';
|
|
105
|
+
/** Verbs for engine lifecycle transitions. */
|
|
106
|
+
export type EngineVerb = 'started' | 'stopped' | 'deployed';
|
|
107
|
+
/** Verbs for worker lifecycle transitions. */
|
|
108
|
+
export type WorkerVerb = 'started' | 'stopped';
|
|
109
|
+
/**
|
|
110
|
+
* Canonical lifecycle event emitted by the SDK post-commit.
|
|
111
|
+
*
|
|
112
|
+
* `event_id` is stable across replays:
|
|
113
|
+
* - Escalation transitions: `${id}:${verb}:${updated_at_iso}` — unique
|
|
114
|
+
* per transition; a re-claim after release gets a new `updated_at`.
|
|
115
|
+
* - Engine / worker events: `${app_id}:${verb}:${ts}`.
|
|
116
|
+
*
|
|
117
|
+
* `data` carries the full committed row (escalation entry) or lifecycle
|
|
118
|
+
* metadata (engine/worker). Consumers cherry-pick what they need; nothing
|
|
119
|
+
* is pre-projected so the shape is future-proof.
|
|
120
|
+
*/
|
|
121
|
+
export interface SystemEvent {
|
|
122
|
+
/** Stable, unique ID per durable transition. */
|
|
123
|
+
event_id: string;
|
|
124
|
+
/**
|
|
125
|
+
* Canonical topic string.
|
|
126
|
+
*
|
|
127
|
+
* | Class | Pattern |
|
|
128
|
+
* |-------------|---------------------------------------|
|
|
129
|
+
* | escalation | `system.escalation.{id}.{verb}` |
|
|
130
|
+
* | engine | `system.engine.{appId}.{verb}` |
|
|
131
|
+
* | worker | `system.worker.{taskQueue}.{verb}` |
|
|
132
|
+
*/
|
|
133
|
+
type: string;
|
|
134
|
+
/** ISO timestamp at emit time (wall-clock, post-commit). */
|
|
135
|
+
ts: string;
|
|
136
|
+
namespace: string;
|
|
137
|
+
app_id: string;
|
|
138
|
+
workflow_id?: string;
|
|
139
|
+
topic?: string;
|
|
140
|
+
origin_id?: string;
|
|
141
|
+
parent_id?: string;
|
|
142
|
+
trace_id?: string;
|
|
143
|
+
span_id?: string;
|
|
144
|
+
/**
|
|
145
|
+
* Full committed row for escalation events; lifecycle metadata for
|
|
146
|
+
* engine/worker events. Long-tail and hike-mono each cherry-pick fields
|
|
147
|
+
* for their own event shape.
|
|
148
|
+
*/
|
|
149
|
+
data: Record<string, unknown>;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* System-event sink configuration. Attach to `HotMeshConfig.events`,
|
|
153
|
+
* `EscalationClientConfig.events`, or `ClientConfig.events` to receive
|
|
154
|
+
* lifecycle events from the SDK.
|
|
155
|
+
*
|
|
156
|
+
* The SDK calls `publish` after each durable transition commits, from the
|
|
157
|
+
* single actor that performed the commit. In a multi-container fleet every
|
|
158
|
+
* container's SDK calls its own `publish` hook — and only for the work it
|
|
159
|
+
* performed — so exactly one container's `publish` fires per real event.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```typescript
|
|
163
|
+
* const events: EventsConfig = {
|
|
164
|
+
* publish: (event) => {
|
|
165
|
+
* // map SystemEvent → your LTEvent shape and hand to NATS/Socket.IO
|
|
166
|
+
* eventRegistry.publish(mapToLTEvent(event));
|
|
167
|
+
* },
|
|
168
|
+
* };
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export interface EventsConfig {
|
|
172
|
+
/**
|
|
173
|
+
* Called post-commit by the performing actor. Fire-and-forget — the SDK
|
|
174
|
+
* does not await the return value; a thrown/rejected promise is silently
|
|
175
|
+
* swallowed so the committed call is never failed by a publish error.
|
|
176
|
+
*/
|
|
177
|
+
publish: (event: SystemEvent) => void | Promise<void>;
|
|
178
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* System-event emission surface for HotMesh lifecycle transitions.
|
|
4
|
+
*
|
|
5
|
+
* The performing actor — the one engine/SDK call that commits a durable
|
|
6
|
+
* transition — fires `EventsConfig.publish` exactly once, inline,
|
|
7
|
+
* post-commit. The hook is fire-and-forget; the SDK never awaits it.
|
|
8
|
+
*
|
|
9
|
+
* ## Ontology
|
|
10
|
+
*
|
|
11
|
+
* Every event has a canonical `type` string and a `data` payload.
|
|
12
|
+
* Consumers pattern-match on `type` and cherry-pick fields from `data`.
|
|
13
|
+
*
|
|
14
|
+
* ### Escalation lifecycle (`system.escalation.{id}.{verb}`)
|
|
15
|
+
*
|
|
16
|
+
* | verb | trigger | `data` shape |
|
|
17
|
+
* |-------------|----------------------------------------------------------|---------------------|
|
|
18
|
+
* | `created` | `client.create()` or hook Leg1 INSERT | full `EscalationEntry` row |
|
|
19
|
+
* | `claimed` | `client.claim()` / `client.claimByMetadata()` | full `EscalationEntry` row |
|
|
20
|
+
* | `released` | `client.release()` | full `EscalationEntry` row |
|
|
21
|
+
* | `reassigned`| `client.escalateToRole()` / role change | full `EscalationEntry` row |
|
|
22
|
+
* | `resolved` | `client.resolve()` / `client.resolveByMetadata()` | full `EscalationEntry` row |
|
|
23
|
+
* | `cancelled` | `client.cancel()` | full `EscalationEntry` row |
|
|
24
|
+
*
|
|
25
|
+
* ### Engine lifecycle (`system.engine.{appId}.{verb}`)
|
|
26
|
+
*
|
|
27
|
+
* | verb | trigger | `data` shape |
|
|
28
|
+
* |------------|----------------------------------|----------------------------------|
|
|
29
|
+
* | `started` | `HotMesh.init()` completes | `{ appId: string, guid: string }` |
|
|
30
|
+
* | `stopped` | `hotMesh.stop()` called | `{ appId: string, guid: string }` |
|
|
31
|
+
* | `deployed` | `kvTables.deploy()` completes | `{ appId: string }` |
|
|
32
|
+
*
|
|
33
|
+
* ### Worker lifecycle (`system.worker.{taskQueue}.{verb}`)
|
|
34
|
+
*
|
|
35
|
+
* | verb | trigger | `data` shape |
|
|
36
|
+
* |------------|----------------------------------|----------------------------------------------|
|
|
37
|
+
* | `started` | `Durable.Worker.create()` ready | `{ taskQueue: string, appId: string }` |
|
|
38
|
+
* | `stopped` | `worker.stop()` called | `{ taskQueue: string, appId: string }` |
|
|
39
|
+
*
|
|
40
|
+
* ## Registration — three construction sites
|
|
41
|
+
*
|
|
42
|
+
* **Site 1 — YAML DAG / hook Leg1 (escalation `created` + engine events):**
|
|
43
|
+
* ```typescript
|
|
44
|
+
* import { HotMesh } from '@hotmeshio/hotmesh';
|
|
45
|
+
* const hm = await HotMesh.init({
|
|
46
|
+
* appId: 'myapp',
|
|
47
|
+
* engine: { connection },
|
|
48
|
+
* events: { publish: (e) => bus.emit(e.type, e) },
|
|
49
|
+
* });
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* **Site 2 — Standalone `EscalationClientService` (all 6 verbs):**
|
|
53
|
+
* ```typescript
|
|
54
|
+
* import { Escalations } from '@hotmeshio/hotmesh';
|
|
55
|
+
* const client = new Escalations.Client({
|
|
56
|
+
* connection,
|
|
57
|
+
* events: { publish: (e) => bus.emit(e.type, e) },
|
|
58
|
+
* });
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* **Site 3 — `Durable.Client` (all 6 verbs via `.escalations`):**
|
|
62
|
+
* ```typescript
|
|
63
|
+
* import { Durable } from '@hotmeshio/hotmesh';
|
|
64
|
+
* const client = new Durable.Client({
|
|
65
|
+
* connection,
|
|
66
|
+
* events: { publish: (e) => bus.emit(e.type, e) },
|
|
67
|
+
* });
|
|
68
|
+
* // client.escalations.* operations now emit lifecycle events
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* ## Event ID format
|
|
72
|
+
*
|
|
73
|
+
* `event_id` is collision-proof across recurrences:
|
|
74
|
+
* - Escalation: `${id}:${verb}:${updated_at_iso}` — claim→release→reclaim
|
|
75
|
+
* produces distinct IDs because `updated_at` changes on each transition.
|
|
76
|
+
* - Engine / worker: `${app_id_or_queue}:${verb}:${ts}`.
|
|
77
|
+
*
|
|
78
|
+
* ## Fire-and-forget contract
|
|
79
|
+
*
|
|
80
|
+
* The SDK wraps every `publish` call in
|
|
81
|
+
* `void Promise.resolve(publish(event)).catch(() => {})`.
|
|
82
|
+
* A slow or throwing `publish` implementation never blocks or fails the
|
|
83
|
+
* committed operation. Use an in-process buffer or async queue if you need
|
|
84
|
+
* back-pressure.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* import { Durable } from '@hotmeshio/hotmesh';
|
|
89
|
+
* import { Client as Postgres } from 'pg';
|
|
90
|
+
*
|
|
91
|
+
* const client = new Durable.Client({
|
|
92
|
+
* connection: { class: Postgres, options: { connectionString: process.env.DATABASE_URL } },
|
|
93
|
+
* events: {
|
|
94
|
+
* publish: (event) => {
|
|
95
|
+
* if (event.type.endsWith('.created')) {
|
|
96
|
+
* // new escalation — route to the right team
|
|
97
|
+
* dispatch(event.data as EscalationEntry);
|
|
98
|
+
* }
|
|
99
|
+
* },
|
|
100
|
+
* },
|
|
101
|
+
* });
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hotmeshio/hotmesh",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.5",
|
|
4
4
|
"description": "Durable Workflow",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"types": "./build/index.d.ts",
|
|
@@ -89,6 +89,7 @@
|
|
|
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
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",
|
|
92
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",
|
|
93
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",
|
|
94
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"
|