@hotmeshio/hotmesh 0.22.1 → 0.22.3
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 +2 -1
- package/build/index.js +3 -1
- package/build/package.json +2 -1
- package/build/services/durable/client.d.ts +8 -273
- package/build/services/durable/client.js +5 -384
- package/build/services/escalations/client.d.ts +168 -0
- package/build/services/escalations/client.js +320 -0
- package/build/services/escalations/index.d.ts +66 -0
- package/build/services/escalations/index.js +71 -0
- package/build/services/store/providers/postgres/kvtables.js +153 -6
- package/build/services/store/providers/postgres/postgres.d.ts +16 -21
- package/build/services/store/providers/postgres/postgres.js +310 -171
- package/build/types/hmsh_escalations.d.ts +85 -6
- package/index.ts +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EscalationClientService = void 0;
|
|
4
|
+
const enums_1 = require("../../modules/enums");
|
|
5
|
+
const utils_1 = require("../../modules/utils");
|
|
6
|
+
const hotmesh_1 = require("../hotmesh");
|
|
7
|
+
const types_1 = require("../../types");
|
|
8
|
+
const factory_1 = require("../durable/schemas/factory");
|
|
9
|
+
/**
|
|
10
|
+
* Standalone client for the `public.hmsh_escalations` signal-pause surface.
|
|
11
|
+
*
|
|
12
|
+
* Requires NO dependency on `services/durable/`. Any HotMesh consumer — AI
|
|
13
|
+
* agent, YAML DAG worker, REST API — can interact with the escalation queue
|
|
14
|
+
* directly with just a Postgres connection.
|
|
15
|
+
*
|
|
16
|
+
* Signal delivery (for `resolve()` / `resolveByMetadata()`) uses HotMesh's
|
|
17
|
+
* `engine.signal()` internally. The engine is initialised lazily on first use
|
|
18
|
+
* and cached for the lifetime of the process.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* import { Escalations } from '@hotmeshio/hotmesh';
|
|
23
|
+
* import { Client as Postgres } from 'pg';
|
|
24
|
+
*
|
|
25
|
+
* const client = new Escalations.Client({
|
|
26
|
+
* connection: {
|
|
27
|
+
* class: Postgres,
|
|
28
|
+
* options: { connectionString: 'postgresql://usr:pwd@localhost:5432/db' },
|
|
29
|
+
* },
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* // Claim the next available approval for the 'manager' role
|
|
33
|
+
* const result = await client.claimByMetadata({
|
|
34
|
+
* key: 'orderId', value: 'order-123',
|
|
35
|
+
* assignee: 'alice@company.com',
|
|
36
|
+
* roles: ['manager'],
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* if (result.ok) {
|
|
40
|
+
* await client.resolve({ id: result.entry.id, resolverPayload: { approved: true } });
|
|
41
|
+
* }
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
class EscalationClientService {
|
|
45
|
+
constructor(config = {}) {
|
|
46
|
+
if (config.getHotMeshClient) {
|
|
47
|
+
// Reuse a caller-supplied engine factory (e.g. Durable.Client) — no extra connections.
|
|
48
|
+
this._engine = config.getHotMeshClient;
|
|
49
|
+
}
|
|
50
|
+
else if (config.connection) {
|
|
51
|
+
this._engine = this._makeEngineFactory(config.connection);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
throw new Error('EscalationClient requires either `connection` or `getHotMeshClient`');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
_makeEngineFactory(connection) {
|
|
58
|
+
return async (topic, namespace) => {
|
|
59
|
+
const optionsHash = this._hashConnection(connection);
|
|
60
|
+
const targetNS = namespace ?? factory_1.APP_ID;
|
|
61
|
+
const key = `esc:${optionsHash}.${targetNS}`;
|
|
62
|
+
if (EscalationClientService.instances.has(key)) {
|
|
63
|
+
return await EscalationClientService.instances.get(key);
|
|
64
|
+
}
|
|
65
|
+
const pending = hotmesh_1.HotMesh.init({
|
|
66
|
+
appId: targetNS,
|
|
67
|
+
taskQueue: topic ?? undefined,
|
|
68
|
+
logLevel: enums_1.HMSH_LOGLEVEL,
|
|
69
|
+
engine: { connection },
|
|
70
|
+
});
|
|
71
|
+
EscalationClientService.instances.set(key, pending);
|
|
72
|
+
return await pending;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
_hashConnection(connection) {
|
|
76
|
+
if ('options' in connection) {
|
|
77
|
+
return (0, utils_1.hashOptions)(connection.options);
|
|
78
|
+
}
|
|
79
|
+
const parts = [];
|
|
80
|
+
for (const p in connection) {
|
|
81
|
+
if (connection[p]?.options) {
|
|
82
|
+
parts.push((0, utils_1.hashOptions)(connection[p].options));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return parts.join('');
|
|
86
|
+
}
|
|
87
|
+
// ─── Signal delivery ───────────────────────────────────────────────────────
|
|
88
|
+
async _deliverEscalationSignal(ns, topic, signalPayload) {
|
|
89
|
+
if (topic) {
|
|
90
|
+
try {
|
|
91
|
+
const tc = await this._engine(topic, ns);
|
|
92
|
+
await tc.engine.signal(topic, signalPayload, types_1.StreamStatus.SUCCESS, 200);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch { /* topic not currently registered — fall through */ }
|
|
96
|
+
}
|
|
97
|
+
let delivered = false;
|
|
98
|
+
try {
|
|
99
|
+
const sc = await this._engine(`${ns}.wfs.signal`, ns);
|
|
100
|
+
await sc.engine.signal(`${ns}.wfs.signal`, signalPayload, types_1.StreamStatus.SUCCESS, 200);
|
|
101
|
+
delivered = true;
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
try {
|
|
105
|
+
const wc = await this._engine(`${ns}.wfs.wait`, ns);
|
|
106
|
+
await wc.engine.signal(`${ns}.wfs.wait`, signalPayload, types_1.StreamStatus.SUCCESS, 200);
|
|
107
|
+
delivered = true;
|
|
108
|
+
}
|
|
109
|
+
catch { }
|
|
110
|
+
return delivered;
|
|
111
|
+
}
|
|
112
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
113
|
+
/**
|
|
114
|
+
* Returns all escalation rows matching the given filters. Each row includes
|
|
115
|
+
* a computed `available` field (true = claimable). Supports `sortBy`,
|
|
116
|
+
* `sortOrder`, `orderBy[]`, and multi-role `roles[]` filter.
|
|
117
|
+
*/
|
|
118
|
+
async list(params) {
|
|
119
|
+
const hm = await this._engine(null, params?.namespace);
|
|
120
|
+
return hm.engine.store.listEscalations(params ?? {});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Returns the count of escalation rows matching the given filters.
|
|
124
|
+
* Uses the same filter parameters as `list()`.
|
|
125
|
+
*/
|
|
126
|
+
async count(params) {
|
|
127
|
+
const hm = await this._engine(null, params?.namespace);
|
|
128
|
+
return hm.engine.store.countEscalations(params ?? {});
|
|
129
|
+
}
|
|
130
|
+
/** Returns a single escalation row by UUID. Returns `null` if not found. */
|
|
131
|
+
async get(id, namespace) {
|
|
132
|
+
const hm = await this._engine(null, namespace);
|
|
133
|
+
return hm.engine.store.getEscalation(id, namespace);
|
|
134
|
+
}
|
|
135
|
+
/** Looks up an escalation by `signal_key` — the value passed to `condition()`. */
|
|
136
|
+
async getBySignalKey(signalKey, namespace) {
|
|
137
|
+
const hm = await this._engine(null, namespace);
|
|
138
|
+
return hm.engine.store.getEscalationBySignalKey(signalKey, namespace);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Creates a standalone escalation row with `signal_key = null`.
|
|
142
|
+
* Useful for external task tracking that doesn't need to resume a workflow.
|
|
143
|
+
*/
|
|
144
|
+
async create(params) {
|
|
145
|
+
const hm = await this._engine(null, params.namespace);
|
|
146
|
+
return hm.engine.store.createEscalation(params);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Patches an existing escalation row. `metadata` is merged, not replaced.
|
|
150
|
+
* Signal routing fields can be enriched after creation.
|
|
151
|
+
*/
|
|
152
|
+
async update(params) {
|
|
153
|
+
const hm = await this._engine(null, params.namespace);
|
|
154
|
+
return hm.engine.store.updateEscalation(params);
|
|
155
|
+
}
|
|
156
|
+
/** Appends milestone entries to the escalation's audit trail. */
|
|
157
|
+
async appendMilestones(params) {
|
|
158
|
+
const hm = await this._engine(null, params.namespace);
|
|
159
|
+
return hm.engine.store.appendEscalationMilestones(params);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Atomically claims an escalation by UUID. Implicit model: `status` stays
|
|
163
|
+
* `'pending'`; claim is expressed via `assigned_to` + `assigned_until`.
|
|
164
|
+
* Returns `isExtension: true` when the same assignee re-claims a row they already hold.
|
|
165
|
+
*/
|
|
166
|
+
async claim(params) {
|
|
167
|
+
const hm = await this._engine(null, params.namespace);
|
|
168
|
+
return hm.engine.store.claimEscalation(params);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Atomically claims the highest-priority pending escalation whose `metadata`
|
|
172
|
+
* contains the given key/value. Optionally merges `metadata` into the claimed row
|
|
173
|
+
* in the same atomic UPDATE. Returns `isExtension: true` when the same assignee
|
|
174
|
+
* re-claims a row they already hold (extends the expiry).
|
|
175
|
+
*/
|
|
176
|
+
async claimByMetadata(params) {
|
|
177
|
+
const hm = await this._engine(null, params.namespace);
|
|
178
|
+
return hm.engine.store.claimEscalationByMetadata(params);
|
|
179
|
+
}
|
|
180
|
+
/** Releases a claimed escalation, returning it to available status. */
|
|
181
|
+
async release(params) {
|
|
182
|
+
const hm = await this._engine(null, params.namespace);
|
|
183
|
+
return hm.engine.store.releaseEscalation(params);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Reassigns the escalation to a different role, clearing any current claim
|
|
187
|
+
* and resetting status to `'pending'`.
|
|
188
|
+
*/
|
|
189
|
+
async escalateToRole(params) {
|
|
190
|
+
const hm = await this._engine(null, params.namespace);
|
|
191
|
+
return hm.engine.store.escalateEscalationToRole(params);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Cancels a pending escalation without delivering a signal. Terminal rows
|
|
195
|
+
* return `already-terminal`.
|
|
196
|
+
*/
|
|
197
|
+
async cancel(id, namespace) {
|
|
198
|
+
const hm = await this._engine(null, namespace);
|
|
199
|
+
return hm.engine.store.cancelEscalation(id, namespace);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Resolves a pending escalation by UUID. Uses an explicit Postgres transaction
|
|
203
|
+
* with FOR UPDATE + WHERE guard: only one concurrent caller can commit the
|
|
204
|
+
* status change; the committed resolved row with its `signal_key` is the
|
|
205
|
+
* durable proof. Signal delivery is best-effort post-commit — the resolved
|
|
206
|
+
* row is the recovery record for any missed delivery. Returns the updated
|
|
207
|
+
* row as `entry` on success.
|
|
208
|
+
*/
|
|
209
|
+
async resolve(params, namespace) {
|
|
210
|
+
const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
|
|
211
|
+
const hm = await this._engine(null, ns);
|
|
212
|
+
const store = hm.engine.store;
|
|
213
|
+
const dbResult = await store.resolveEscalation({ id: params.id, resolverPayload: params.resolverPayload });
|
|
214
|
+
if (!dbResult.ok)
|
|
215
|
+
return dbResult;
|
|
216
|
+
if (dbResult.signalKey) {
|
|
217
|
+
await this._deliverEscalationSignal(ns, dbResult.topic, {
|
|
218
|
+
id: dbResult.signalKey,
|
|
219
|
+
data: params.resolverPayload ?? {},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return { ok: true, entry: dbResult.entry };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Resolves the highest-priority matching escalation by metadata filter,
|
|
226
|
+
* then delivers its signal. Same transaction + WHERE guard semantics as `resolve()`.
|
|
227
|
+
*/
|
|
228
|
+
async resolveByMetadata(params, namespace) {
|
|
229
|
+
const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
|
|
230
|
+
const hm = await this._engine(null, ns);
|
|
231
|
+
const store = hm.engine.store;
|
|
232
|
+
const dbResult = await store.resolveEscalationByMetadata({ key: params.key, value: params.value, resolverPayload: params.resolverPayload, roles: params.roles });
|
|
233
|
+
if (!dbResult.ok)
|
|
234
|
+
return dbResult;
|
|
235
|
+
if (dbResult.signalKey) {
|
|
236
|
+
await this._deliverEscalationSignal(ns, dbResult.topic, {
|
|
237
|
+
id: dbResult.signalKey,
|
|
238
|
+
data: params.resolverPayload ?? {},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return { ok: true, entry: dbResult.entry };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Full-fidelity migration: inserts an escalation row preserving the original
|
|
245
|
+
* UUID and lifecycle state. Returns `null` on duplicate (idempotent).
|
|
246
|
+
*/
|
|
247
|
+
async migrate(params, namespace) {
|
|
248
|
+
const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
|
|
249
|
+
const hm = await this._engine(null, ns);
|
|
250
|
+
return hm.engine.store.createEscalationForMigration(params);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* No-op in the implicit claim model — availability is computed at query time
|
|
254
|
+
* from `assigned_until`. Kept for API compatibility.
|
|
255
|
+
*/
|
|
256
|
+
async releaseExpired(namespace) {
|
|
257
|
+
const hm = await this._engine(null, namespace);
|
|
258
|
+
return hm.engine.store.releaseExpiredEscalations(namespace);
|
|
259
|
+
}
|
|
260
|
+
// ─── Bulk operations ────────────────────────────────────────────────────────
|
|
261
|
+
/**
|
|
262
|
+
* Bulk-claims up to `ids.length` pending escalations in one statement.
|
|
263
|
+
* Returns `{ claimed, skipped }` — skipped rows are either already claimed
|
|
264
|
+
* by another assignee or non-existent. Implicit-claim semantics apply.
|
|
265
|
+
*/
|
|
266
|
+
async claimMany(params) {
|
|
267
|
+
const hm = await this._engine(null, params.namespace);
|
|
268
|
+
return hm.engine.store.claimManyEscalations(params);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Bulk-reassigns pending escalations to a new role, clearing any current claim.
|
|
272
|
+
* Returns the count of rows updated.
|
|
273
|
+
*/
|
|
274
|
+
async escalateManyToRole(params) {
|
|
275
|
+
const hm = await this._engine(null, params.namespace);
|
|
276
|
+
return hm.engine.store.escalateManyEscalationsToRole(params);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Bulk-updates priority for pending escalations. Returns the count of rows updated.
|
|
280
|
+
*/
|
|
281
|
+
async updateManyPriority(params) {
|
|
282
|
+
const hm = await this._engine(null, params.namespace);
|
|
283
|
+
return hm.engine.store.updateManyEscalationsPriority(params);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Bulk-resolves pending escalations by id-set. No signal delivery — intended
|
|
287
|
+
* for redirect-to-triage flows where no workflow is waiting. Returns the
|
|
288
|
+
* resolved rows.
|
|
289
|
+
*/
|
|
290
|
+
async resolveMany(params) {
|
|
291
|
+
const hm = await this._engine(null, params.namespace);
|
|
292
|
+
return hm.engine.store.resolveManyEscalations(params);
|
|
293
|
+
}
|
|
294
|
+
// ─── Aggregates ─────────────────────────────────────────────────────────────
|
|
295
|
+
/**
|
|
296
|
+
* Returns dashboard-ready escalation counts. `period` controls the window
|
|
297
|
+
* used for `created` and `resolved` counts (default `'24h'`). When `roles`
|
|
298
|
+
* is an empty array, all counts are zero (RBAC guard).
|
|
299
|
+
*/
|
|
300
|
+
async stats(params) {
|
|
301
|
+
const hm = await this._engine(null, params?.namespace);
|
|
302
|
+
return hm.engine.store.escalationStats(params ?? {});
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Returns the sorted list of distinct `type` values in the escalations table.
|
|
306
|
+
* Useful for populating filter dropdowns.
|
|
307
|
+
*/
|
|
308
|
+
async listDistinctTypes(namespace) {
|
|
309
|
+
const hm = await this._engine(null, namespace);
|
|
310
|
+
return hm.engine.store.listDistinctEscalationTypes(namespace);
|
|
311
|
+
}
|
|
312
|
+
static async shutdown() {
|
|
313
|
+
for (const [_, instance] of EscalationClientService.instances) {
|
|
314
|
+
(await instance).stop();
|
|
315
|
+
}
|
|
316
|
+
EscalationClientService.instances.clear();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
EscalationClientService.instances = new Map();
|
|
320
|
+
exports.EscalationClientService = EscalationClientService;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { guid, uuid } from '../../modules/utils';
|
|
2
|
+
import { EscalationClientService } from './client';
|
|
3
|
+
/**
|
|
4
|
+
* Escalation queue — the canonical signal-pause surface for HotMesh workflows.
|
|
5
|
+
*
|
|
6
|
+
* `public.hmsh_escalations` is a global Postgres table written by both the
|
|
7
|
+
* YAML DAG hook activity and `Durable.workflow.condition()`. Any consumer
|
|
8
|
+
* can interact with the queue using just a Postgres connection — no Durable
|
|
9
|
+
* dependency required.
|
|
10
|
+
*
|
|
11
|
+
* ## Quick Start
|
|
12
|
+
*
|
|
13
|
+
* ```typescript
|
|
14
|
+
* import { Escalations } from '@hotmeshio/hotmesh';
|
|
15
|
+
* import { Client as Postgres } from 'pg';
|
|
16
|
+
*
|
|
17
|
+
* const client = new Escalations.Client({
|
|
18
|
+
* connection: {
|
|
19
|
+
* class: Postgres,
|
|
20
|
+
* options: { connectionString: 'postgresql://usr:pwd@localhost:5432/db' },
|
|
21
|
+
* },
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // List all available approvals for the 'manager' role
|
|
25
|
+
* const pending = await client.list({ role: 'manager', available: true });
|
|
26
|
+
*
|
|
27
|
+
* // Claim one atomically
|
|
28
|
+
* const result = await client.claimByMetadata({
|
|
29
|
+
* key: 'orderId', value: 'order-123',
|
|
30
|
+
* assignee: 'alice@company.com',
|
|
31
|
+
* roles: ['manager', 'senior-manager'],
|
|
32
|
+
* });
|
|
33
|
+
*
|
|
34
|
+
* // Resolve and resume the waiting workflow in one round-trip
|
|
35
|
+
* if (result.ok) {
|
|
36
|
+
* await client.resolve({ id: result.entry.id, resolverPayload: { approved: true } });
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
declare class EscalationsClass {
|
|
41
|
+
/** @private */
|
|
42
|
+
constructor();
|
|
43
|
+
/**
|
|
44
|
+
* Creates an escalation queue client. Pass a `connection` for standalone
|
|
45
|
+
* use, or inject a `getHotMeshClient` function to share an existing
|
|
46
|
+
* engine pool (e.g. from a `Durable.Client`).
|
|
47
|
+
*/
|
|
48
|
+
static Client: typeof EscalationClientService;
|
|
49
|
+
/**
|
|
50
|
+
* Generate a compact HotMesh identifier (not RFC 4122).
|
|
51
|
+
* Use `Escalations.uuid()` for DB primary keys.
|
|
52
|
+
*/
|
|
53
|
+
static guid: typeof guid;
|
|
54
|
+
/**
|
|
55
|
+
* Generate a standard RFC 4122 v4 UUID — required for the `id` field
|
|
56
|
+
* when calling `client.migrate()` or any direct UUID column insert.
|
|
57
|
+
*/
|
|
58
|
+
static uuid: typeof uuid;
|
|
59
|
+
/**
|
|
60
|
+
* Gracefully stop all escalation engine instances.
|
|
61
|
+
* Call from your process signal handlers (`SIGTERM`, `SIGINT`).
|
|
62
|
+
*/
|
|
63
|
+
static shutdown(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
export { EscalationsClass as Escalations };
|
|
66
|
+
export { EscalationClientService };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EscalationClientService = exports.Escalations = void 0;
|
|
4
|
+
const utils_1 = require("../../modules/utils");
|
|
5
|
+
const client_1 = require("./client");
|
|
6
|
+
Object.defineProperty(exports, "EscalationClientService", { enumerable: true, get: function () { return client_1.EscalationClientService; } });
|
|
7
|
+
/**
|
|
8
|
+
* Escalation queue — the canonical signal-pause surface for HotMesh workflows.
|
|
9
|
+
*
|
|
10
|
+
* `public.hmsh_escalations` is a global Postgres table written by both the
|
|
11
|
+
* YAML DAG hook activity and `Durable.workflow.condition()`. Any consumer
|
|
12
|
+
* can interact with the queue using just a Postgres connection — no Durable
|
|
13
|
+
* dependency required.
|
|
14
|
+
*
|
|
15
|
+
* ## Quick Start
|
|
16
|
+
*
|
|
17
|
+
* ```typescript
|
|
18
|
+
* import { Escalations } from '@hotmeshio/hotmesh';
|
|
19
|
+
* import { Client as Postgres } from 'pg';
|
|
20
|
+
*
|
|
21
|
+
* const client = new Escalations.Client({
|
|
22
|
+
* connection: {
|
|
23
|
+
* class: Postgres,
|
|
24
|
+
* options: { connectionString: 'postgresql://usr:pwd@localhost:5432/db' },
|
|
25
|
+
* },
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* // List all available approvals for the 'manager' role
|
|
29
|
+
* const pending = await client.list({ role: 'manager', available: true });
|
|
30
|
+
*
|
|
31
|
+
* // Claim one atomically
|
|
32
|
+
* const result = await client.claimByMetadata({
|
|
33
|
+
* key: 'orderId', value: 'order-123',
|
|
34
|
+
* assignee: 'alice@company.com',
|
|
35
|
+
* roles: ['manager', 'senior-manager'],
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* // Resolve and resume the waiting workflow in one round-trip
|
|
39
|
+
* if (result.ok) {
|
|
40
|
+
* await client.resolve({ id: result.entry.id, resolverPayload: { approved: true } });
|
|
41
|
+
* }
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
class EscalationsClass {
|
|
45
|
+
/** @private */
|
|
46
|
+
constructor() { }
|
|
47
|
+
/**
|
|
48
|
+
* Gracefully stop all escalation engine instances.
|
|
49
|
+
* Call from your process signal handlers (`SIGTERM`, `SIGINT`).
|
|
50
|
+
*/
|
|
51
|
+
static async shutdown() {
|
|
52
|
+
await client_1.EscalationClientService.shutdown();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
exports.Escalations = EscalationsClass;
|
|
56
|
+
/**
|
|
57
|
+
* Creates an escalation queue client. Pass a `connection` for standalone
|
|
58
|
+
* use, or inject a `getHotMeshClient` function to share an existing
|
|
59
|
+
* engine pool (e.g. from a `Durable.Client`).
|
|
60
|
+
*/
|
|
61
|
+
EscalationsClass.Client = client_1.EscalationClientService;
|
|
62
|
+
/**
|
|
63
|
+
* Generate a compact HotMesh identifier (not RFC 4122).
|
|
64
|
+
* Use `Escalations.uuid()` for DB primary keys.
|
|
65
|
+
*/
|
|
66
|
+
EscalationsClass.guid = utils_1.guid;
|
|
67
|
+
/**
|
|
68
|
+
* Generate a standard RFC 4122 v4 UUID — required for the `id` field
|
|
69
|
+
* when calling `client.migrate()` or any direct UUID column insert.
|
|
70
|
+
*/
|
|
71
|
+
EscalationsClass.uuid = utils_1.uuid;
|
|
@@ -166,6 +166,145 @@ const KVTables = (context) => ({
|
|
|
166
166
|
ON ${jobsTable} (key) WHERE is_live;
|
|
167
167
|
`);
|
|
168
168
|
}
|
|
169
|
+
// v0.22.0: origin_id, parent_id on per-app jobs table (workflow lineage).
|
|
170
|
+
// ADD COLUMN IF NOT EXISTS on a nullable column with no DEFAULT is catalog-only
|
|
171
|
+
// in PG11+ — no table rewrite, brief AccessExclusiveLock only.
|
|
172
|
+
await client.query(`
|
|
173
|
+
ALTER TABLE ${jobsTable} ADD COLUMN IF NOT EXISTS origin_id TEXT;
|
|
174
|
+
`);
|
|
175
|
+
await client.query(`
|
|
176
|
+
ALTER TABLE ${jobsTable} ADD COLUMN IF NOT EXISTS parent_id TEXT;
|
|
177
|
+
`);
|
|
178
|
+
// Partial indexes — only cover the opt-in rows so build is near-instant.
|
|
179
|
+
const { rows: originIdxRows } = await client.query(`SELECT 1 FROM pg_indexes WHERE indexname = $1 AND schemaname = $2 LIMIT 1`, [`idx_${schemaName}_jobs_origin`, schemaName]);
|
|
180
|
+
if (originIdxRows.length === 0) {
|
|
181
|
+
await client.query(`
|
|
182
|
+
CREATE INDEX IF NOT EXISTS idx_${schemaName}_jobs_origin
|
|
183
|
+
ON ${jobsTable}(origin_id) WHERE origin_id IS NOT NULL;
|
|
184
|
+
`);
|
|
185
|
+
}
|
|
186
|
+
const { rows: parentIdxRows } = await client.query(`SELECT 1 FROM pg_indexes WHERE indexname = $1 AND schemaname = $2 LIMIT 1`, [`idx_${schemaName}_jobs_parent`, schemaName]);
|
|
187
|
+
if (parentIdxRows.length === 0) {
|
|
188
|
+
await client.query(`
|
|
189
|
+
CREATE INDEX IF NOT EXISTS idx_${schemaName}_jobs_parent
|
|
190
|
+
ON ${jobsTable}(parent_id) WHERE parent_id IS NOT NULL;
|
|
191
|
+
`);
|
|
192
|
+
}
|
|
193
|
+
// ─── v0.22.0+: public.hmsh_escalations global table migrations ───────────
|
|
194
|
+
// Use the fixed advisory XACT lock so concurrent deployments of DIFFERENT
|
|
195
|
+
// appIds (each holding their own per-appId session lock) don't race on the
|
|
196
|
+
// shared public table DDL. Transaction-level lock auto-releases on commit/rollback.
|
|
197
|
+
await client.query('BEGIN');
|
|
198
|
+
await client.query('SELECT pg_advisory_xact_lock($1)', [0x484D5348]);
|
|
199
|
+
// v0.22.0: create hmsh_escalations if missing (upgrade from pre-0.22.x).
|
|
200
|
+
// Column list matches createTables() exactly; task_id is NOT here because
|
|
201
|
+
// the ADD COLUMN below is the canonical way to add it to both fresh and
|
|
202
|
+
// existing tables in a single idempotent statement.
|
|
203
|
+
await client.query(`
|
|
204
|
+
CREATE TABLE IF NOT EXISTS public.hmsh_escalations (
|
|
205
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
206
|
+
namespace TEXT NOT NULL,
|
|
207
|
+
app_id TEXT NOT NULL,
|
|
208
|
+
signal_key TEXT,
|
|
209
|
+
topic TEXT,
|
|
210
|
+
workflow_id TEXT,
|
|
211
|
+
task_queue TEXT,
|
|
212
|
+
workflow_type TEXT,
|
|
213
|
+
type TEXT,
|
|
214
|
+
subtype TEXT,
|
|
215
|
+
entity TEXT,
|
|
216
|
+
description TEXT,
|
|
217
|
+
role TEXT,
|
|
218
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
219
|
+
priority INT NOT NULL DEFAULT 5,
|
|
220
|
+
assigned_to TEXT,
|
|
221
|
+
assigned_until TIMESTAMPTZ,
|
|
222
|
+
claimed_at TIMESTAMPTZ,
|
|
223
|
+
claim_expires_at TIMESTAMPTZ,
|
|
224
|
+
resolved_at TIMESTAMPTZ,
|
|
225
|
+
escalation_payload JSONB,
|
|
226
|
+
resolver_payload JSONB,
|
|
227
|
+
envelope JSONB,
|
|
228
|
+
metadata JSONB,
|
|
229
|
+
origin_id TEXT,
|
|
230
|
+
parent_id TEXT,
|
|
231
|
+
initiated_by TEXT,
|
|
232
|
+
created_by TEXT,
|
|
233
|
+
milestones JSONB NOT NULL DEFAULT '[]',
|
|
234
|
+
trace_id TEXT,
|
|
235
|
+
span_id TEXT,
|
|
236
|
+
expires_at TIMESTAMPTZ,
|
|
237
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
238
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
239
|
+
);
|
|
240
|
+
`);
|
|
241
|
+
// v0.22.3: task_id column — catalog-only ADD COLUMN (nullable TEXT, no DEFAULT,
|
|
242
|
+
// no table rewrite). IF NOT EXISTS makes this idempotent across all upgrade paths.
|
|
243
|
+
await client.query(`
|
|
244
|
+
ALTER TABLE public.hmsh_escalations ADD COLUMN IF NOT EXISTS task_id TEXT;
|
|
245
|
+
`);
|
|
246
|
+
// Ensure signal_key unique index exists.
|
|
247
|
+
await client.query(`
|
|
248
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hmsh_esc_signal_key
|
|
249
|
+
ON public.hmsh_escalations(namespace, app_id, signal_key)
|
|
250
|
+
WHERE signal_key IS NOT NULL;
|
|
251
|
+
`);
|
|
252
|
+
// v0.22.0: idx_hmsh_esc_available — predicate must be status='pending'.
|
|
253
|
+
// Fresh 0.22.0 installs may have this with the correct predicate already;
|
|
254
|
+
// ensure it exists.
|
|
255
|
+
await client.query(`
|
|
256
|
+
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_available
|
|
257
|
+
ON public.hmsh_escalations(namespace, app_id, role, priority ASC, created_at ASC)
|
|
258
|
+
WHERE status = 'pending';
|
|
259
|
+
`);
|
|
260
|
+
await client.query(`
|
|
261
|
+
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_available_expiry
|
|
262
|
+
ON public.hmsh_escalations(namespace, app_id, role, assigned_until, created_at DESC);
|
|
263
|
+
`);
|
|
264
|
+
// v0.22.2: idx_hmsh_esc_assigned predicate changed from status='claimed' to
|
|
265
|
+
// status='pending' to match the implicit claim model. If the old predicate
|
|
266
|
+
// is present, drop and recreate so claimEscalation / list queries use it.
|
|
267
|
+
const { rows: assignedDefRows } = await client.query(`
|
|
268
|
+
SELECT indexdef FROM pg_indexes WHERE indexname = 'idx_hmsh_esc_assigned' LIMIT 1
|
|
269
|
+
`);
|
|
270
|
+
if (assignedDefRows.length > 0 &&
|
|
271
|
+
assignedDefRows[0].indexdef.includes("status = 'claimed'")) {
|
|
272
|
+
await client.query(`DROP INDEX IF EXISTS idx_hmsh_esc_assigned;`);
|
|
273
|
+
}
|
|
274
|
+
await client.query(`
|
|
275
|
+
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_assigned
|
|
276
|
+
ON public.hmsh_escalations(assigned_to, assigned_until, created_at DESC)
|
|
277
|
+
WHERE status = 'pending' AND assigned_to IS NOT NULL;
|
|
278
|
+
`);
|
|
279
|
+
// v0.22.2: claim_expiry sweeper index is obsolete (releaseExpiredEscalations
|
|
280
|
+
// is a no-op in the implicit-claim model — availability is query-time computed).
|
|
281
|
+
await client.query(`DROP INDEX IF EXISTS idx_hmsh_esc_claim_expiry;`);
|
|
282
|
+
await client.query(`
|
|
283
|
+
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_entity
|
|
284
|
+
ON public.hmsh_escalations(namespace, app_id, entity, created_at DESC)
|
|
285
|
+
WHERE entity IS NOT NULL;
|
|
286
|
+
`);
|
|
287
|
+
await client.query(`
|
|
288
|
+
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_workflow
|
|
289
|
+
ON public.hmsh_escalations(workflow_id)
|
|
290
|
+
WHERE workflow_id IS NOT NULL;
|
|
291
|
+
`);
|
|
292
|
+
await client.query(`
|
|
293
|
+
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_origin
|
|
294
|
+
ON public.hmsh_escalations(origin_id)
|
|
295
|
+
WHERE origin_id IS NOT NULL;
|
|
296
|
+
`);
|
|
297
|
+
await client.query(`
|
|
298
|
+
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_metadata
|
|
299
|
+
ON public.hmsh_escalations USING GIN(metadata jsonb_path_ops);
|
|
300
|
+
`);
|
|
301
|
+
// v0.22.3: task_id partial index.
|
|
302
|
+
await client.query(`
|
|
303
|
+
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_task
|
|
304
|
+
ON public.hmsh_escalations(task_id)
|
|
305
|
+
WHERE task_id IS NOT NULL;
|
|
306
|
+
`);
|
|
307
|
+
await client.query('COMMIT');
|
|
169
308
|
},
|
|
170
309
|
async createTables(client, appName) {
|
|
171
310
|
try {
|
|
@@ -253,16 +392,15 @@ const KVTables = (context) => ({
|
|
|
253
392
|
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_available_expiry
|
|
254
393
|
ON public.hmsh_escalations(namespace, app_id, role, assigned_until, created_at DESC);
|
|
255
394
|
`);
|
|
395
|
+
// idx_hmsh_esc_assigned: implicit-claim model uses status='pending', not 'claimed'
|
|
396
|
+
await client.query(`DROP INDEX IF EXISTS idx_hmsh_esc_assigned;`);
|
|
256
397
|
await client.query(`
|
|
257
398
|
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_assigned
|
|
258
399
|
ON public.hmsh_escalations(assigned_to, assigned_until, created_at DESC)
|
|
259
|
-
WHERE status = '
|
|
260
|
-
`);
|
|
261
|
-
await client.query(`
|
|
262
|
-
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_claim_expiry
|
|
263
|
-
ON public.hmsh_escalations(claim_expires_at)
|
|
264
|
-
WHERE status = 'claimed';
|
|
400
|
+
WHERE status = 'pending' AND assigned_to IS NOT NULL;
|
|
265
401
|
`);
|
|
402
|
+
// idx_hmsh_esc_claim_expiry: no longer used (releaseExpiredEscalations is a no-op)
|
|
403
|
+
await client.query(`DROP INDEX IF EXISTS idx_hmsh_esc_claim_expiry;`);
|
|
266
404
|
await client.query(`
|
|
267
405
|
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_entity
|
|
268
406
|
ON public.hmsh_escalations(namespace, app_id, entity, created_at DESC)
|
|
@@ -281,6 +419,15 @@ const KVTables = (context) => ({
|
|
|
281
419
|
await client.query(`
|
|
282
420
|
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_metadata
|
|
283
421
|
ON public.hmsh_escalations USING GIN(metadata jsonb_path_ops);
|
|
422
|
+
`);
|
|
423
|
+
// task_id: nullable passthrough column for downstream compat (no FK)
|
|
424
|
+
await client.query(`
|
|
425
|
+
ALTER TABLE public.hmsh_escalations ADD COLUMN IF NOT EXISTS task_id TEXT;
|
|
426
|
+
`);
|
|
427
|
+
await client.query(`
|
|
428
|
+
CREATE INDEX IF NOT EXISTS idx_hmsh_esc_task
|
|
429
|
+
ON public.hmsh_escalations(task_id)
|
|
430
|
+
WHERE task_id IS NOT NULL;
|
|
284
431
|
`);
|
|
285
432
|
break;
|
|
286
433
|
case 'relational_connection':
|