@hotmeshio/hotmesh 0.20.0 → 0.21.0
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/README.md +134 -0
- package/build/package.json +1 -1
- package/build/services/durable/client.d.ts +66 -0
- package/build/services/durable/client.js +151 -0
- package/build/services/durable/index.d.ts +2 -0
- package/build/services/durable/worker.js +25 -0
- package/build/services/durable/workflow/condition.d.ts +5 -2
- package/build/services/durable/workflow/condition.js +8 -2
- package/build/services/store/providers/postgres/kvtables.js +122 -0
- package/build/services/store/providers/postgres/postgres.d.ts +158 -0
- package/build/services/store/providers/postgres/postgres.js +276 -0
- package/build/services/stream/providers/postgres/kvtables.js +16 -1
- package/build/types/index.d.ts +1 -0
- package/build/types/signal.d.ts +147 -0
- package/build/types/signal.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ npm install @hotmeshio/hotmesh
|
|
|
14
14
|
- **Crash-safe execution** — Every step is a committed row. If the process dies, it picks up where it left off.
|
|
15
15
|
- **Distributed state machines** — Build stateful applications where every component can [fail and recover](https://github.com/hotmeshio/sdk-typescript/blob/main/services/collator/README.md).
|
|
16
16
|
- **AI and training pipelines** — Multi-step AI workloads where each stage is expensive and must not be repeated on failure. A crashed pipeline resumes from the last committed step, not from the beginning.
|
|
17
|
+
- **Human-in-the-loop queues** — Suspend workflows until an operator claims and resolves the task. Pending suspensions become discoverable, claimable rows in Postgres. Concurrent workers receive exact reason codes (`conflict`, `already-resolved`, `signal-failed`) instead of opaque nulls.
|
|
17
18
|
|
|
18
19
|
## How it works in 30 seconds
|
|
19
20
|
|
|
@@ -166,10 +167,37 @@ const result = await Durable.workflow.executeChild({
|
|
|
166
167
|
**Signals** — pause a workflow until an external event arrives.
|
|
167
168
|
|
|
168
169
|
```typescript
|
|
170
|
+
// Basic: pause until any caller sends the matching signal
|
|
169
171
|
const approval = await Durable.workflow.condition<{ approved: boolean }>('manager-approval');
|
|
170
172
|
if (!approval.approved) return 'rejected';
|
|
171
173
|
```
|
|
172
174
|
|
|
175
|
+
Pass a `ConditionQueueConfig` as the second argument to also create a claimable task record in Postgres (see [Signal queue](#signal-queue-postgres-only) below):
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
// With queue config: atomically suspend the workflow AND enqueue a claimable task record
|
|
179
|
+
const approval = await Durable.workflow.condition<{ approved: boolean }>('manager-approval', {
|
|
180
|
+
role: 'manager',
|
|
181
|
+
description: `Approve order ${orderId}`,
|
|
182
|
+
metadata: { orderId, region: 'us-west' }, // GIN-indexed; used for claimByMetadata queries
|
|
183
|
+
envelope: { formSchema: [...] }, // unindexed display context; safe for large blobs
|
|
184
|
+
});
|
|
185
|
+
// approval is false if a timeout was set and no signal arrived
|
|
186
|
+
if (approval === false) return 'timed-out';
|
|
187
|
+
if (!approval.approved) return 'rejected';
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The optional first string argument is a timeout; the queue config can appear as the second or third argument:
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
// With timeout AND queue config
|
|
194
|
+
const approval = await Durable.workflow.condition<{ approved: boolean }>(
|
|
195
|
+
'manager-approval',
|
|
196
|
+
'72 hours', // workflow times out if nobody acts
|
|
197
|
+
{ role: 'manager', metadata: { orderId } },
|
|
198
|
+
);
|
|
199
|
+
```
|
|
200
|
+
|
|
173
201
|
## Retries and error handling
|
|
174
202
|
|
|
175
203
|
Activities retry automatically on failure. Configure the policy per activity or per worker:
|
|
@@ -243,6 +271,112 @@ const exported = await handle.export({ // selective export
|
|
|
243
271
|
});
|
|
244
272
|
```
|
|
245
273
|
|
|
274
|
+
## Signal queue (Postgres only)
|
|
275
|
+
|
|
276
|
+
When `condition()` is called with a `ConditionQueueConfig`, HotMesh atomically writes a row to `hotmesh_signals` inside the same Postgres transaction that suspends the workflow. The result is a durable, queryable task queue backed by the same database — no separate service, no double-write, no gap between "task created" and "workflow suspended".
|
|
277
|
+
|
|
278
|
+
**Workflow side** — declare what kind of task this suspension represents:
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// workflows/approval.ts
|
|
282
|
+
export async function orderApproval(orderId: string) {
|
|
283
|
+
const signalId = `approve-${orderId}`;
|
|
284
|
+
|
|
285
|
+
const result = await Durable.workflow.condition<{ approved: boolean; notes: string }>(
|
|
286
|
+
signalId,
|
|
287
|
+
{
|
|
288
|
+
role: 'pharmacist',
|
|
289
|
+
type: 'approval',
|
|
290
|
+
priority: 3,
|
|
291
|
+
description: `Review order ${orderId}`,
|
|
292
|
+
taskQueue: 'rx-approvals',
|
|
293
|
+
workflowType: 'orderApproval',
|
|
294
|
+
metadata: { orderId }, // GIN-indexed; query with claimByMetadata
|
|
295
|
+
envelope: { // not indexed; pass form schemas, display context
|
|
296
|
+
formSchema: [{ name: 'notes', type: 'textarea', required: true }],
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (result === false) return { status: 'timed-out' };
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
**Resolver side** — claim and resolve from any process (API handler, worker, dashboard):
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
// resolver.ts
|
|
310
|
+
import { Durable } from '@hotmeshio/hotmesh';
|
|
311
|
+
|
|
312
|
+
const client = new Durable.Client({ connection });
|
|
313
|
+
|
|
314
|
+
// List all pending tasks for a role
|
|
315
|
+
const pending = await client.signalQueue.list({ role: 'pharmacist', status: 'pending' });
|
|
316
|
+
|
|
317
|
+
// Claim by metadata — atomic; concurrent workers get 'conflict', not a silent null
|
|
318
|
+
const claim = await client.signalQueue.claimByMetadata({
|
|
319
|
+
key: 'orderId',
|
|
320
|
+
value: orderId,
|
|
321
|
+
assignee: 'jane@example.com',
|
|
322
|
+
durationMinutes: 30,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (!claim.ok) {
|
|
326
|
+
// claim.reason: 'not-found' (nothing queued) | 'conflict' (another worker beat you)
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Resolve — marks the DB record resolved AND delivers the signal to the paused workflow
|
|
331
|
+
const resolution = await client.signalQueue.resolve({
|
|
332
|
+
id: claim.entry.id,
|
|
333
|
+
resolverPayload: { approved: true, notes: 'LGTM' },
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
if (!resolution.ok) {
|
|
337
|
+
if (resolution.reason === 'signal-failed') {
|
|
338
|
+
// DB updated but workflow signal not delivered — retry with resolution.signalKey
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
**All `signalQueue` methods:**
|
|
344
|
+
|
|
345
|
+
| Method | Description |
|
|
346
|
+
|---|---|
|
|
347
|
+
| `list(params)` | List signals filtered by `role`, `status`, `taskQueue` |
|
|
348
|
+
| `get(id)` | Fetch a single signal record by UUID |
|
|
349
|
+
| `claim({ id, assignee?, durationMinutes? })` | Claim by record ID |
|
|
350
|
+
| `claimByMetadata({ key, value, assignee?, durationMinutes? })` | Claim by GIN-indexed metadata field |
|
|
351
|
+
| `release({ id })` | Return a claimed signal to `pending` |
|
|
352
|
+
| `releaseExpired()` | Sweep all past-expiry claims back to `pending` |
|
|
353
|
+
| `resolve({ id, resolverPayload? })` | Resolve by ID and deliver signal to the workflow |
|
|
354
|
+
| `resolveByMetadata({ key, value, resolverPayload? })` | Resolve by metadata field and deliver signal |
|
|
355
|
+
|
|
356
|
+
**Result types** — all mutating methods return a discriminated union so callers can handle every outcome without guessing what `null` meant:
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
// Claim
|
|
360
|
+
type ClaimSignalResult =
|
|
361
|
+
| { ok: true; entry: SignalQueueEntry }
|
|
362
|
+
| { ok: false; reason: 'not-found' | 'conflict' };
|
|
363
|
+
|
|
364
|
+
// Release
|
|
365
|
+
type ReleaseSignalResult =
|
|
366
|
+
| { ok: true }
|
|
367
|
+
| { ok: false; reason: 'not-found' | 'wrong-status' };
|
|
368
|
+
|
|
369
|
+
// Resolve
|
|
370
|
+
type ResolveSignalResult =
|
|
371
|
+
| { ok: true }
|
|
372
|
+
| { ok: false; reason: 'not-found' | 'already-resolved' }
|
|
373
|
+
| { ok: false; reason: 'signal-failed'; signalKey: string };
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
`signal-failed` means the database record was updated but `workflow.signal()` threw (network blip, workflow already complete). The `signalKey` is returned so callers can retry signal delivery independently without re-resolving the record.
|
|
377
|
+
|
|
378
|
+
> **Postgres only.** The `signalQueue.*` methods require the Postgres provider. Workflows using `condition()` without a queue config are unaffected on any provider.
|
|
379
|
+
|
|
246
380
|
## Observability
|
|
247
381
|
|
|
248
382
|
There is no proprietary dashboard. Workflow state lives in Postgres, so use whatever tools you already have:
|
package/build/package.json
CHANGED
|
@@ -82,6 +82,72 @@ export declare class ClientService {
|
|
|
82
82
|
* is accessed by calling workflow.start().
|
|
83
83
|
*/
|
|
84
84
|
workflow: ClientWorkflow;
|
|
85
|
+
/**
|
|
86
|
+
* Signal queue API for managing paused-workflow task records.
|
|
87
|
+
* Operations: list, get, claim, claimByMetadata, release, resolve,
|
|
88
|
+
* resolveByMetadata, releaseExpired.
|
|
89
|
+
*
|
|
90
|
+
* Requires a Postgres store provider. Methods are no-ops (return null/false)
|
|
91
|
+
* when called against a non-Postgres store.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```typescript
|
|
95
|
+
* // Claim a pending task by metadata key
|
|
96
|
+
* const task = await client.signalQueue.claimByMetadata({
|
|
97
|
+
* key: 'orderId', value: 'RX-123',
|
|
98
|
+
* assignee: 'pharmacist-jane',
|
|
99
|
+
* durationMinutes: 30,
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* if (task) {
|
|
103
|
+
* await client.signalQueue.resolve({
|
|
104
|
+
* id: task.id,
|
|
105
|
+
* resolverPayload: { approved: true },
|
|
106
|
+
* });
|
|
107
|
+
* // → paused workflow resumes with { approved: true }
|
|
108
|
+
* }
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
signalQueue: {
|
|
112
|
+
list: (params?: {
|
|
113
|
+
namespace?: string;
|
|
114
|
+
taskQueue?: string;
|
|
115
|
+
status?: 'pending' | 'claimed' | 'resolved' | 'expired' | 'released';
|
|
116
|
+
role?: string;
|
|
117
|
+
limit?: number;
|
|
118
|
+
offset?: number;
|
|
119
|
+
}) => Promise<any>;
|
|
120
|
+
get: (id: string, namespace?: string) => Promise<any>;
|
|
121
|
+
claim: (params: {
|
|
122
|
+
id: string;
|
|
123
|
+
namespace?: string;
|
|
124
|
+
assignee?: string;
|
|
125
|
+
durationMinutes?: number;
|
|
126
|
+
}) => Promise<import('../../types/signal').ClaimSignalResult>;
|
|
127
|
+
claimByMetadata: (params: {
|
|
128
|
+
key: string;
|
|
129
|
+
value: unknown;
|
|
130
|
+
namespace?: string;
|
|
131
|
+
assignee?: string;
|
|
132
|
+
durationMinutes?: number;
|
|
133
|
+
}) => Promise<import('../../types/signal').ClaimSignalResult>;
|
|
134
|
+
release: (params: {
|
|
135
|
+
id: string;
|
|
136
|
+
namespace?: string;
|
|
137
|
+
}) => Promise<import('../../types/signal').ReleaseSignalResult>;
|
|
138
|
+
resolve: (params: {
|
|
139
|
+
id: string;
|
|
140
|
+
namespace?: string;
|
|
141
|
+
resolverPayload?: Record<string, unknown>;
|
|
142
|
+
}) => Promise<import('../../types/signal').ResolveSignalResult>;
|
|
143
|
+
resolveByMetadata: (params: {
|
|
144
|
+
key: string;
|
|
145
|
+
value: unknown;
|
|
146
|
+
namespace?: string;
|
|
147
|
+
resolverPayload?: Record<string, unknown>;
|
|
148
|
+
}) => Promise<import('../../types/signal').ResolveSignalResult>;
|
|
149
|
+
releaseExpired: (namespace?: string) => Promise<number>;
|
|
150
|
+
};
|
|
85
151
|
/**
|
|
86
152
|
* Any router can be used to deploy and activate the HotMesh
|
|
87
153
|
* distributed executable to the active quorum EXCEPT for
|
|
@@ -284,6 +284,157 @@ class ClientService {
|
|
|
284
284
|
}
|
|
285
285
|
},
|
|
286
286
|
};
|
|
287
|
+
/**
|
|
288
|
+
* Signal queue API for managing paused-workflow task records.
|
|
289
|
+
* Operations: list, get, claim, claimByMetadata, release, resolve,
|
|
290
|
+
* resolveByMetadata, releaseExpired.
|
|
291
|
+
*
|
|
292
|
+
* Requires a Postgres store provider. Methods are no-ops (return null/false)
|
|
293
|
+
* when called against a non-Postgres store.
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* ```typescript
|
|
297
|
+
* // Claim a pending task by metadata key
|
|
298
|
+
* const task = await client.signalQueue.claimByMetadata({
|
|
299
|
+
* key: 'orderId', value: 'RX-123',
|
|
300
|
+
* assignee: 'pharmacist-jane',
|
|
301
|
+
* durationMinutes: 30,
|
|
302
|
+
* });
|
|
303
|
+
*
|
|
304
|
+
* if (task) {
|
|
305
|
+
* await client.signalQueue.resolve({
|
|
306
|
+
* id: task.id,
|
|
307
|
+
* resolverPayload: { approved: true },
|
|
308
|
+
* });
|
|
309
|
+
* // → paused workflow resumes with { approved: true }
|
|
310
|
+
* }
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
this.signalQueue = {
|
|
314
|
+
list: async (params = {}) => {
|
|
315
|
+
const hotMesh = await this.getHotMeshClient(null, params.namespace);
|
|
316
|
+
const store = hotMesh.engine.store;
|
|
317
|
+
if (typeof store.listSignals !== 'function')
|
|
318
|
+
return [];
|
|
319
|
+
const ns = params.namespace ?? factory_1.APP_ID;
|
|
320
|
+
return store.listSignals({
|
|
321
|
+
namespace: ns,
|
|
322
|
+
appId: store.appId,
|
|
323
|
+
status: params.status,
|
|
324
|
+
role: params.role,
|
|
325
|
+
taskQueue: params.taskQueue,
|
|
326
|
+
limit: params.limit,
|
|
327
|
+
offset: params.offset,
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
get: async (id, namespace) => {
|
|
331
|
+
const hotMesh = await this.getHotMeshClient(null, namespace);
|
|
332
|
+
const store = hotMesh.engine.store;
|
|
333
|
+
if (typeof store.getSignal !== 'function')
|
|
334
|
+
return null;
|
|
335
|
+
const ns = namespace ?? factory_1.APP_ID;
|
|
336
|
+
return store.getSignal({ namespace: ns, appId: store.appId, id });
|
|
337
|
+
},
|
|
338
|
+
claim: async (params) => {
|
|
339
|
+
const hotMesh = await this.getHotMeshClient(null, params.namespace);
|
|
340
|
+
const store = hotMesh.engine.store;
|
|
341
|
+
if (typeof store.claimSignal !== 'function') {
|
|
342
|
+
return { ok: false, reason: 'not-found' };
|
|
343
|
+
}
|
|
344
|
+
const ns = params.namespace ?? factory_1.APP_ID;
|
|
345
|
+
return store.claimSignal({
|
|
346
|
+
namespace: ns,
|
|
347
|
+
appId: store.appId,
|
|
348
|
+
id: params.id,
|
|
349
|
+
assignee: params.assignee,
|
|
350
|
+
durationMinutes: params.durationMinutes,
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
claimByMetadata: async (params) => {
|
|
354
|
+
const hotMesh = await this.getHotMeshClient(null, params.namespace);
|
|
355
|
+
const store = hotMesh.engine.store;
|
|
356
|
+
if (typeof store.claimSignalByMetadata !== 'function') {
|
|
357
|
+
return { ok: false, reason: 'not-found' };
|
|
358
|
+
}
|
|
359
|
+
const ns = params.namespace ?? factory_1.APP_ID;
|
|
360
|
+
return store.claimSignalByMetadata({
|
|
361
|
+
namespace: ns,
|
|
362
|
+
appId: store.appId,
|
|
363
|
+
key: params.key,
|
|
364
|
+
value: params.value,
|
|
365
|
+
assignee: params.assignee,
|
|
366
|
+
durationMinutes: params.durationMinutes,
|
|
367
|
+
});
|
|
368
|
+
},
|
|
369
|
+
release: async (params) => {
|
|
370
|
+
const hotMesh = await this.getHotMeshClient(null, params.namespace);
|
|
371
|
+
const store = hotMesh.engine.store;
|
|
372
|
+
if (typeof store.releaseSignal !== 'function') {
|
|
373
|
+
return { ok: false, reason: 'not-found' };
|
|
374
|
+
}
|
|
375
|
+
const ns = params.namespace ?? factory_1.APP_ID;
|
|
376
|
+
return store.releaseSignal({
|
|
377
|
+
namespace: ns,
|
|
378
|
+
appId: store.appId,
|
|
379
|
+
id: params.id,
|
|
380
|
+
});
|
|
381
|
+
},
|
|
382
|
+
resolve: async (params) => {
|
|
383
|
+
const hotMesh = await this.getHotMeshClient(null, params.namespace);
|
|
384
|
+
const store = hotMesh.engine.store;
|
|
385
|
+
if (typeof store.resolveSignal !== 'function') {
|
|
386
|
+
return { ok: false, reason: 'not-found' };
|
|
387
|
+
}
|
|
388
|
+
const ns = params.namespace ?? factory_1.APP_ID;
|
|
389
|
+
const storeResult = await store.resolveSignal({
|
|
390
|
+
namespace: ns,
|
|
391
|
+
appId: store.appId,
|
|
392
|
+
id: params.id,
|
|
393
|
+
resolverPayload: params.resolverPayload,
|
|
394
|
+
});
|
|
395
|
+
if (!storeResult.ok)
|
|
396
|
+
return storeResult;
|
|
397
|
+
try {
|
|
398
|
+
await this.workflow.signal(storeResult.signalKey, params.resolverPayload ?? {}, params.namespace);
|
|
399
|
+
return { ok: true };
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return { ok: false, reason: 'signal-failed', signalKey: storeResult.signalKey };
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
resolveByMetadata: async (params) => {
|
|
406
|
+
const hotMesh = await this.getHotMeshClient(null, params.namespace);
|
|
407
|
+
const store = hotMesh.engine.store;
|
|
408
|
+
if (typeof store.resolveSignalByMetadata !== 'function') {
|
|
409
|
+
return { ok: false, reason: 'not-found' };
|
|
410
|
+
}
|
|
411
|
+
const ns = params.namespace ?? factory_1.APP_ID;
|
|
412
|
+
const storeResult = await store.resolveSignalByMetadata({
|
|
413
|
+
namespace: ns,
|
|
414
|
+
appId: store.appId,
|
|
415
|
+
key: params.key,
|
|
416
|
+
value: params.value,
|
|
417
|
+
resolverPayload: params.resolverPayload,
|
|
418
|
+
});
|
|
419
|
+
if (!storeResult.ok)
|
|
420
|
+
return storeResult;
|
|
421
|
+
try {
|
|
422
|
+
await this.workflow.signal(storeResult.signalKey, params.resolverPayload ?? {}, params.namespace);
|
|
423
|
+
return { ok: true };
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return { ok: false, reason: 'signal-failed', signalKey: storeResult.signalKey };
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
releaseExpired: async (namespace) => {
|
|
430
|
+
const hotMesh = await this.getHotMeshClient(null, namespace);
|
|
431
|
+
const store = hotMesh.engine.store;
|
|
432
|
+
if (typeof store.releaseExpiredSignals !== 'function')
|
|
433
|
+
return 0;
|
|
434
|
+
const ns = namespace ?? factory_1.APP_ID;
|
|
435
|
+
return store.releaseExpiredSignals({ namespace: ns, appId: store.appId });
|
|
436
|
+
},
|
|
437
|
+
};
|
|
287
438
|
this.connection = config.connection;
|
|
288
439
|
}
|
|
289
440
|
hashOptions() {
|
|
@@ -318,3 +318,5 @@ declare class DurableClass {
|
|
|
318
318
|
}
|
|
319
319
|
export { DurableClass as Durable };
|
|
320
320
|
export type { ContextType };
|
|
321
|
+
export type { ConditionQueueConfig } from './workflow/condition';
|
|
322
|
+
export type { ClaimSignalResult, ReleaseSignalResult, ResolveSignalResult, SignalQueueEntry, } from '../../types/signal';
|
|
@@ -806,6 +806,31 @@ class WorkerService {
|
|
|
806
806
|
const workflowInput = data.data;
|
|
807
807
|
const execIndex = counter.counter;
|
|
808
808
|
const { workflowId, workflowDimension, originJobId } = workflowInput;
|
|
809
|
+
const payload = interruptionRegistry[0];
|
|
810
|
+
//if condition() was called with queueConfig, create a signal queue record
|
|
811
|
+
if (payload.queueConfig) {
|
|
812
|
+
const store = this.workflowRunner.engine.store;
|
|
813
|
+
if (typeof store.enqueueSignal === 'function') {
|
|
814
|
+
const ns = config.namespace ?? factory_1.APP_ID;
|
|
815
|
+
try {
|
|
816
|
+
await store.enqueueSignal({
|
|
817
|
+
namespace: ns,
|
|
818
|
+
appId: store.appId,
|
|
819
|
+
signalKey: payload.signalId,
|
|
820
|
+
workflowId,
|
|
821
|
+
topic: `${ns}.wfs.wait`,
|
|
822
|
+
taskQueue: config.taskQueue ?? payload.queueConfig.taskQueue,
|
|
823
|
+
...payload.queueConfig,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
catch (enqueueErr) {
|
|
827
|
+
this.workflowRunner.engine.logger.warn('signal-queue-enqueue-err', {
|
|
828
|
+
signalId: payload.signalId,
|
|
829
|
+
error: enqueueErr,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
809
834
|
return withPatchMarkers({
|
|
810
835
|
status: stream_1.StreamStatus.SUCCESS,
|
|
811
836
|
code: enums_1.HMSH_CODE_DURABLE_WAIT,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ConditionQueueConfig } from '../../../types/signal';
|
|
2
|
+
export type { ConditionQueueConfig };
|
|
1
3
|
/**
|
|
2
4
|
* Pauses the workflow until a signal with the given `signalId` is received.
|
|
3
5
|
* The workflow suspends durably — it survives process restarts and will
|
|
@@ -61,7 +63,8 @@
|
|
|
61
63
|
*
|
|
62
64
|
* @template T - The type of data expected in the signal payload.
|
|
63
65
|
* @param {string} signalId - A unique signal identifier shared by the sender and receiver.
|
|
64
|
-
* @param {string} [
|
|
66
|
+
* @param {string | ConditionQueueConfig} [timeoutOrConfig] - Optional timeout string (e.g. '30s') OR queue config object.
|
|
67
|
+
* @param {ConditionQueueConfig} [queueConfig] - Optional queue config when timeout is also provided.
|
|
65
68
|
* @returns {Promise<T | false>} The signal data, or `false` if the timeout expired first.
|
|
66
69
|
*/
|
|
67
|
-
export declare function condition<T>(signalId: string,
|
|
70
|
+
export declare function condition<T>(signalId: string, timeoutOrConfig?: string | ConditionQueueConfig, queueConfig?: ConditionQueueConfig): Promise<T | false>;
|
|
@@ -67,10 +67,15 @@ const didRun_1 = require("./didRun");
|
|
|
67
67
|
*
|
|
68
68
|
* @template T - The type of data expected in the signal payload.
|
|
69
69
|
* @param {string} signalId - A unique signal identifier shared by the sender and receiver.
|
|
70
|
-
* @param {string} [
|
|
70
|
+
* @param {string | ConditionQueueConfig} [timeoutOrConfig] - Optional timeout string (e.g. '30s') OR queue config object.
|
|
71
|
+
* @param {ConditionQueueConfig} [queueConfig] - Optional queue config when timeout is also provided.
|
|
71
72
|
* @returns {Promise<T | false>} The signal data, or `false` if the timeout expired first.
|
|
72
73
|
*/
|
|
73
|
-
async function condition(signalId,
|
|
74
|
+
async function condition(signalId, timeoutOrConfig, queueConfig) {
|
|
75
|
+
const timeout = typeof timeoutOrConfig === 'string' ? timeoutOrConfig : undefined;
|
|
76
|
+
const resolvedQueueConfig = typeof timeoutOrConfig === 'object' && timeoutOrConfig !== null
|
|
77
|
+
? timeoutOrConfig
|
|
78
|
+
: queueConfig;
|
|
74
79
|
const [didRunAlready, execIndex, result] = await (0, didRun_1.didRun)('wait');
|
|
75
80
|
(0, cancellationScope_1.checkCancellation)();
|
|
76
81
|
if (didRunAlready) {
|
|
@@ -117,6 +122,7 @@ async function condition(signalId, timeout) {
|
|
|
117
122
|
type: 'DurableWaitForError',
|
|
118
123
|
code: common_1.HMSH_CODE_DURABLE_WAIT,
|
|
119
124
|
...(timeout ? { duration: (0, common_1.s)(timeout) } : {}),
|
|
125
|
+
...(resolvedQueueConfig ? { queueConfig: resolvedQueueConfig } : {}),
|
|
120
126
|
};
|
|
121
127
|
interruptionRegistry.push(interruptionMessage);
|
|
122
128
|
await (0, common_1.sleepImmediate)();
|
|
@@ -166,6 +166,65 @@ const KVTables = (context) => ({
|
|
|
166
166
|
ON ${jobsTable} (key) WHERE is_live;
|
|
167
167
|
`);
|
|
168
168
|
}
|
|
169
|
+
// v0.21.0: signal queue table for first-class HITL escalation primitives
|
|
170
|
+
const sigTable = `${schemaName}.hotmesh_signals`;
|
|
171
|
+
const { rows: sigRows } = await client.query(`SELECT to_regclass('${sigTable}') AS tbl`);
|
|
172
|
+
if (!sigRows[0].tbl) {
|
|
173
|
+
await client.query(`
|
|
174
|
+
CREATE TABLE IF NOT EXISTS ${sigTable} (
|
|
175
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
176
|
+
namespace TEXT NOT NULL,
|
|
177
|
+
app_id TEXT NOT NULL,
|
|
178
|
+
signal_key TEXT NOT NULL,
|
|
179
|
+
workflow_id TEXT NOT NULL,
|
|
180
|
+
job_id TEXT,
|
|
181
|
+
topic TEXT,
|
|
182
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
183
|
+
role TEXT,
|
|
184
|
+
type TEXT,
|
|
185
|
+
subtype TEXT,
|
|
186
|
+
priority INT NOT NULL DEFAULT 5,
|
|
187
|
+
description TEXT,
|
|
188
|
+
task_queue TEXT,
|
|
189
|
+
workflow_type TEXT,
|
|
190
|
+
assigned_to TEXT,
|
|
191
|
+
claimed_at TIMESTAMPTZ,
|
|
192
|
+
claim_expires_at TIMESTAMPTZ,
|
|
193
|
+
resolved_at TIMESTAMPTZ,
|
|
194
|
+
resolver_payload JSONB,
|
|
195
|
+
envelope JSONB,
|
|
196
|
+
metadata JSONB,
|
|
197
|
+
expires_at TIMESTAMPTZ,
|
|
198
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
199
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
200
|
+
UNIQUE(namespace, app_id, signal_key)
|
|
201
|
+
);
|
|
202
|
+
`);
|
|
203
|
+
await client.query(`
|
|
204
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_namespace_status
|
|
205
|
+
ON ${sigTable}(namespace, app_id, status);
|
|
206
|
+
`);
|
|
207
|
+
await client.query(`
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_signal_key
|
|
209
|
+
ON ${sigTable}(namespace, app_id, signal_key);
|
|
210
|
+
`);
|
|
211
|
+
await client.query(`
|
|
212
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_role_status
|
|
213
|
+
ON ${sigTable}(namespace, app_id, role, status);
|
|
214
|
+
`);
|
|
215
|
+
await client.query(`
|
|
216
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_task_queue
|
|
217
|
+
ON ${sigTable}(namespace, app_id, task_queue, status);
|
|
218
|
+
`);
|
|
219
|
+
await client.query(`
|
|
220
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_metadata_gin
|
|
221
|
+
ON ${sigTable} USING GIN(metadata);
|
|
222
|
+
`);
|
|
223
|
+
await client.query(`
|
|
224
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_claim_expiry
|
|
225
|
+
ON ${sigTable}(claim_expires_at) WHERE status = 'claimed';
|
|
226
|
+
`);
|
|
227
|
+
}
|
|
169
228
|
},
|
|
170
229
|
async createTables(client, appName) {
|
|
171
230
|
try {
|
|
@@ -476,6 +535,64 @@ const KVTables = (context) => ({
|
|
|
476
535
|
ON ${fullTableName} (key, score, member);
|
|
477
536
|
`);
|
|
478
537
|
break;
|
|
538
|
+
case 'signal_queue': {
|
|
539
|
+
const tbl = fullTableName;
|
|
540
|
+
await client.query(`
|
|
541
|
+
CREATE TABLE IF NOT EXISTS ${tbl} (
|
|
542
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
543
|
+
namespace TEXT NOT NULL,
|
|
544
|
+
app_id TEXT NOT NULL,
|
|
545
|
+
signal_key TEXT NOT NULL,
|
|
546
|
+
workflow_id TEXT NOT NULL,
|
|
547
|
+
job_id TEXT,
|
|
548
|
+
topic TEXT,
|
|
549
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
550
|
+
role TEXT,
|
|
551
|
+
type TEXT,
|
|
552
|
+
subtype TEXT,
|
|
553
|
+
priority INT NOT NULL DEFAULT 5,
|
|
554
|
+
description TEXT,
|
|
555
|
+
task_queue TEXT,
|
|
556
|
+
workflow_type TEXT,
|
|
557
|
+
assigned_to TEXT,
|
|
558
|
+
claimed_at TIMESTAMPTZ,
|
|
559
|
+
claim_expires_at TIMESTAMPTZ,
|
|
560
|
+
resolved_at TIMESTAMPTZ,
|
|
561
|
+
resolver_payload JSONB,
|
|
562
|
+
envelope JSONB,
|
|
563
|
+
metadata JSONB,
|
|
564
|
+
expires_at TIMESTAMPTZ,
|
|
565
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
566
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
567
|
+
UNIQUE(namespace, app_id, signal_key)
|
|
568
|
+
);
|
|
569
|
+
`);
|
|
570
|
+
await client.query(`
|
|
571
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_namespace_status
|
|
572
|
+
ON ${tbl}(namespace, app_id, status);
|
|
573
|
+
`);
|
|
574
|
+
await client.query(`
|
|
575
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_signal_key
|
|
576
|
+
ON ${tbl}(namespace, app_id, signal_key);
|
|
577
|
+
`);
|
|
578
|
+
await client.query(`
|
|
579
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_role_status
|
|
580
|
+
ON ${tbl}(namespace, app_id, role, status);
|
|
581
|
+
`);
|
|
582
|
+
await client.query(`
|
|
583
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_task_queue
|
|
584
|
+
ON ${tbl}(namespace, app_id, task_queue, status);
|
|
585
|
+
`);
|
|
586
|
+
await client.query(`
|
|
587
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_metadata_gin
|
|
588
|
+
ON ${tbl} USING GIN(metadata);
|
|
589
|
+
`);
|
|
590
|
+
await client.query(`
|
|
591
|
+
CREATE INDEX IF NOT EXISTS idx_hmsig_claim_expiry
|
|
592
|
+
ON ${tbl}(claim_expires_at) WHERE status = 'claimed';
|
|
593
|
+
`);
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
479
596
|
default:
|
|
480
597
|
context.logger.warn(`Unknown table type for ${tableDef.name}`);
|
|
481
598
|
break;
|
|
@@ -593,6 +710,11 @@ const KVTables = (context) => ({
|
|
|
593
710
|
name: 'signal_registry',
|
|
594
711
|
type: 'string',
|
|
595
712
|
},
|
|
713
|
+
{
|
|
714
|
+
schema: schemaName,
|
|
715
|
+
name: 'hotmesh_signals',
|
|
716
|
+
type: 'signal_queue',
|
|
717
|
+
},
|
|
596
718
|
];
|
|
597
719
|
return tableDefinitions;
|
|
598
720
|
},
|
|
@@ -11,6 +11,7 @@ import { Transitions } from '../../../../types/transition';
|
|
|
11
11
|
import { JobInterruptOptions } from '../../../../types/job';
|
|
12
12
|
import { WorkListTaskType } from '../../../../types/task';
|
|
13
13
|
import { ThrottleOptions } from '../../../../types/quorum';
|
|
14
|
+
import { SignalQueueEntry } from '../../../../types/signal';
|
|
14
15
|
import { StoreService } from '../..';
|
|
15
16
|
import { PostgresClientType } from '../../../../types';
|
|
16
17
|
import { KVSQL } from './kvsql';
|
|
@@ -222,6 +223,163 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
222
223
|
activity?: string;
|
|
223
224
|
types?: string[];
|
|
224
225
|
}): Promise<import('../../../../types/exporter').StreamHistoryEntry[]>;
|
|
226
|
+
private signalQueueTable;
|
|
227
|
+
private rowToSignalEntry;
|
|
228
|
+
enqueueSignal(params: {
|
|
229
|
+
namespace: string;
|
|
230
|
+
appId: string;
|
|
231
|
+
signalKey: string;
|
|
232
|
+
workflowId: string;
|
|
233
|
+
jobId?: string;
|
|
234
|
+
topic?: string;
|
|
235
|
+
role?: string;
|
|
236
|
+
type?: string;
|
|
237
|
+
subtype?: string;
|
|
238
|
+
priority?: number;
|
|
239
|
+
description?: string;
|
|
240
|
+
taskQueue?: string;
|
|
241
|
+
workflowType?: string;
|
|
242
|
+
assignedTo?: string;
|
|
243
|
+
metadata?: Record<string, unknown>;
|
|
244
|
+
envelope?: Record<string, unknown>;
|
|
245
|
+
expiresAt?: Date;
|
|
246
|
+
}): Promise<{
|
|
247
|
+
id: string;
|
|
248
|
+
} | null>;
|
|
249
|
+
claimSignal(params: {
|
|
250
|
+
namespace: string;
|
|
251
|
+
appId: string;
|
|
252
|
+
id: string;
|
|
253
|
+
assignee?: string;
|
|
254
|
+
durationMinutes?: number;
|
|
255
|
+
}): Promise<{
|
|
256
|
+
ok: true;
|
|
257
|
+
entry: SignalQueueEntry;
|
|
258
|
+
} | {
|
|
259
|
+
ok: false;
|
|
260
|
+
reason: 'not-found' | 'conflict';
|
|
261
|
+
}>;
|
|
262
|
+
claimSignalByMetadata(params: {
|
|
263
|
+
namespace: string;
|
|
264
|
+
appId: string;
|
|
265
|
+
key: string;
|
|
266
|
+
value: unknown;
|
|
267
|
+
assignee?: string;
|
|
268
|
+
durationMinutes?: number;
|
|
269
|
+
}): Promise<{
|
|
270
|
+
ok: true;
|
|
271
|
+
entry: SignalQueueEntry;
|
|
272
|
+
} | {
|
|
273
|
+
ok: false;
|
|
274
|
+
reason: 'not-found' | 'conflict';
|
|
275
|
+
}>;
|
|
276
|
+
releaseSignal(params: {
|
|
277
|
+
namespace: string;
|
|
278
|
+
appId: string;
|
|
279
|
+
id: string;
|
|
280
|
+
}): Promise<{
|
|
281
|
+
ok: true;
|
|
282
|
+
} | {
|
|
283
|
+
ok: false;
|
|
284
|
+
reason: 'not-found' | 'wrong-status';
|
|
285
|
+
}>;
|
|
286
|
+
resolveSignal(params: {
|
|
287
|
+
namespace: string;
|
|
288
|
+
appId: string;
|
|
289
|
+
id: string;
|
|
290
|
+
resolverPayload?: Record<string, unknown>;
|
|
291
|
+
}): Promise<{
|
|
292
|
+
ok: true;
|
|
293
|
+
signalKey: string;
|
|
294
|
+
topic: string;
|
|
295
|
+
} | {
|
|
296
|
+
ok: false;
|
|
297
|
+
reason: 'not-found' | 'already-resolved';
|
|
298
|
+
}>;
|
|
299
|
+
resolveSignalByMetadata(params: {
|
|
300
|
+
namespace: string;
|
|
301
|
+
appId: string;
|
|
302
|
+
key: string;
|
|
303
|
+
value: unknown;
|
|
304
|
+
resolverPayload?: Record<string, unknown>;
|
|
305
|
+
}): Promise<{
|
|
306
|
+
ok: true;
|
|
307
|
+
signalKey: string;
|
|
308
|
+
topic: string;
|
|
309
|
+
} | {
|
|
310
|
+
ok: false;
|
|
311
|
+
reason: 'not-found';
|
|
312
|
+
}>;
|
|
313
|
+
releaseExpiredSignals(params: {
|
|
314
|
+
namespace: string;
|
|
315
|
+
appId: string;
|
|
316
|
+
}): Promise<number>;
|
|
317
|
+
listSignals(params: {
|
|
318
|
+
namespace: string;
|
|
319
|
+
appId: string;
|
|
320
|
+
status?: string;
|
|
321
|
+
role?: string;
|
|
322
|
+
taskQueue?: string;
|
|
323
|
+
limit?: number;
|
|
324
|
+
offset?: number;
|
|
325
|
+
}): Promise<{
|
|
326
|
+
id: string;
|
|
327
|
+
namespace: string;
|
|
328
|
+
appId: string;
|
|
329
|
+
signalKey: string;
|
|
330
|
+
workflowId: string;
|
|
331
|
+
jobId: string;
|
|
332
|
+
topic: string;
|
|
333
|
+
status: "pending" | "claimed" | "resolved" | "expired" | "released";
|
|
334
|
+
role: string;
|
|
335
|
+
type: string;
|
|
336
|
+
subtype: string;
|
|
337
|
+
priority: number;
|
|
338
|
+
description: string;
|
|
339
|
+
taskQueue: string;
|
|
340
|
+
workflowType: string;
|
|
341
|
+
assignedTo: string;
|
|
342
|
+
claimedAt: Date;
|
|
343
|
+
claimExpiresAt: Date;
|
|
344
|
+
resolvedAt: Date;
|
|
345
|
+
resolverPayload: Record<string, unknown>;
|
|
346
|
+
envelope: Record<string, unknown>;
|
|
347
|
+
metadata: Record<string, unknown>;
|
|
348
|
+
expiresAt: Date;
|
|
349
|
+
createdAt: Date;
|
|
350
|
+
updatedAt: Date;
|
|
351
|
+
}[]>;
|
|
352
|
+
getSignal(params: {
|
|
353
|
+
namespace: string;
|
|
354
|
+
appId: string;
|
|
355
|
+
id: string;
|
|
356
|
+
}): Promise<{
|
|
357
|
+
id: string;
|
|
358
|
+
namespace: string;
|
|
359
|
+
appId: string;
|
|
360
|
+
signalKey: string;
|
|
361
|
+
workflowId: string;
|
|
362
|
+
jobId: string;
|
|
363
|
+
topic: string;
|
|
364
|
+
status: "pending" | "claimed" | "resolved" | "expired" | "released";
|
|
365
|
+
role: string;
|
|
366
|
+
type: string;
|
|
367
|
+
subtype: string;
|
|
368
|
+
priority: number;
|
|
369
|
+
description: string;
|
|
370
|
+
taskQueue: string;
|
|
371
|
+
workflowType: string;
|
|
372
|
+
assignedTo: string;
|
|
373
|
+
claimedAt: Date;
|
|
374
|
+
claimExpiresAt: Date;
|
|
375
|
+
resolvedAt: Date;
|
|
376
|
+
resolverPayload: Record<string, unknown>;
|
|
377
|
+
envelope: Record<string, unknown>;
|
|
378
|
+
metadata: Record<string, unknown>;
|
|
379
|
+
expiresAt: Date;
|
|
380
|
+
createdAt: Date;
|
|
381
|
+
updatedAt: Date;
|
|
382
|
+
}>;
|
|
225
383
|
/**
|
|
226
384
|
* Parse a HotMesh-encoded value string.
|
|
227
385
|
* Values may be prefixed with `/s` (JSON), `/d` (number), `/t` or `/f` (boolean), `/n` (null).
|
|
@@ -1368,6 +1368,282 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1368
1368
|
};
|
|
1369
1369
|
});
|
|
1370
1370
|
}
|
|
1371
|
+
// ─── Signal Queue Methods ─────────────────────────────────────────────────
|
|
1372
|
+
signalQueueTable() {
|
|
1373
|
+
return `${this.kvsql().safeName(this.appId)}.hotmesh_signals`;
|
|
1374
|
+
}
|
|
1375
|
+
rowToSignalEntry(row) {
|
|
1376
|
+
return {
|
|
1377
|
+
id: row.id,
|
|
1378
|
+
namespace: row.namespace,
|
|
1379
|
+
appId: row.app_id,
|
|
1380
|
+
signalKey: row.signal_key,
|
|
1381
|
+
workflowId: row.workflow_id,
|
|
1382
|
+
jobId: row.job_id,
|
|
1383
|
+
topic: row.topic,
|
|
1384
|
+
status: row.status,
|
|
1385
|
+
role: row.role,
|
|
1386
|
+
type: row.type,
|
|
1387
|
+
subtype: row.subtype,
|
|
1388
|
+
priority: row.priority,
|
|
1389
|
+
description: row.description,
|
|
1390
|
+
taskQueue: row.task_queue,
|
|
1391
|
+
workflowType: row.workflow_type,
|
|
1392
|
+
assignedTo: row.assigned_to,
|
|
1393
|
+
claimedAt: row.claimed_at ? new Date(row.claimed_at) : undefined,
|
|
1394
|
+
claimExpiresAt: row.claim_expires_at ? new Date(row.claim_expires_at) : undefined,
|
|
1395
|
+
resolvedAt: row.resolved_at ? new Date(row.resolved_at) : undefined,
|
|
1396
|
+
resolverPayload: row.resolver_payload,
|
|
1397
|
+
envelope: row.envelope,
|
|
1398
|
+
metadata: row.metadata,
|
|
1399
|
+
expiresAt: row.expires_at ? new Date(row.expires_at) : undefined,
|
|
1400
|
+
createdAt: new Date(row.created_at),
|
|
1401
|
+
updatedAt: new Date(row.updated_at),
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
async enqueueSignal(params) {
|
|
1405
|
+
const tbl = this.signalQueueTable();
|
|
1406
|
+
const result = await this.pgClient.query(`INSERT INTO ${tbl}
|
|
1407
|
+
(namespace, app_id, signal_key, workflow_id, job_id, topic,
|
|
1408
|
+
role, type, subtype, priority, description,
|
|
1409
|
+
task_queue, workflow_type, assigned_to,
|
|
1410
|
+
metadata, envelope, expires_at)
|
|
1411
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
|
1412
|
+
ON CONFLICT (namespace, app_id, signal_key) DO NOTHING
|
|
1413
|
+
RETURNING id`, [
|
|
1414
|
+
params.namespace,
|
|
1415
|
+
params.appId,
|
|
1416
|
+
params.signalKey,
|
|
1417
|
+
params.workflowId,
|
|
1418
|
+
params.jobId ?? null,
|
|
1419
|
+
params.topic ?? null,
|
|
1420
|
+
params.role ?? null,
|
|
1421
|
+
params.type ?? null,
|
|
1422
|
+
params.subtype ?? null,
|
|
1423
|
+
params.priority ?? 5,
|
|
1424
|
+
params.description ?? null,
|
|
1425
|
+
params.taskQueue ?? null,
|
|
1426
|
+
params.workflowType ?? null,
|
|
1427
|
+
params.assignedTo ?? null,
|
|
1428
|
+
params.metadata ? JSON.stringify(params.metadata) : null,
|
|
1429
|
+
params.envelope ? JSON.stringify(params.envelope) : null,
|
|
1430
|
+
params.expiresAt ?? null,
|
|
1431
|
+
]);
|
|
1432
|
+
if (result.rows.length === 0)
|
|
1433
|
+
return null;
|
|
1434
|
+
return { id: result.rows[0].id };
|
|
1435
|
+
}
|
|
1436
|
+
async claimSignal(params) {
|
|
1437
|
+
const tbl = this.signalQueueTable();
|
|
1438
|
+
const minutes = params.durationMinutes ?? 30;
|
|
1439
|
+
const result = await this.pgClient.query(`WITH found AS (
|
|
1440
|
+
SELECT id, status FROM ${tbl}
|
|
1441
|
+
WHERE namespace = $1 AND app_id = $2 AND id = $4
|
|
1442
|
+
),
|
|
1443
|
+
claimed AS (
|
|
1444
|
+
UPDATE ${tbl}
|
|
1445
|
+
SET status = 'claimed',
|
|
1446
|
+
claimed_at = NOW(),
|
|
1447
|
+
claim_expires_at = NOW() + INTERVAL '${minutes} minutes',
|
|
1448
|
+
assigned_to = COALESCE($3, assigned_to),
|
|
1449
|
+
updated_at = NOW()
|
|
1450
|
+
WHERE namespace = $1 AND app_id = $2
|
|
1451
|
+
AND id = $4
|
|
1452
|
+
AND status = 'pending'
|
|
1453
|
+
RETURNING *
|
|
1454
|
+
)
|
|
1455
|
+
SELECT c.*, f.id AS f_id
|
|
1456
|
+
FROM found f
|
|
1457
|
+
LEFT JOIN claimed c ON c.id = f.id`, [params.namespace, params.appId, params.assignee ?? null, params.id]);
|
|
1458
|
+
if (result.rows.length === 0 || !result.rows[0].f_id) {
|
|
1459
|
+
return { ok: false, reason: 'not-found' };
|
|
1460
|
+
}
|
|
1461
|
+
if (!result.rows[0].id) {
|
|
1462
|
+
return { ok: false, reason: 'conflict' };
|
|
1463
|
+
}
|
|
1464
|
+
return { ok: true, entry: this.rowToSignalEntry(result.rows[0]) };
|
|
1465
|
+
}
|
|
1466
|
+
async claimSignalByMetadata(params) {
|
|
1467
|
+
const tbl = this.signalQueueTable();
|
|
1468
|
+
const minutes = params.durationMinutes ?? 30;
|
|
1469
|
+
const containsJson = JSON.stringify({ [params.key]: params.value });
|
|
1470
|
+
const result = await this.pgClient.query(`WITH found AS (
|
|
1471
|
+
SELECT id, status FROM ${tbl}
|
|
1472
|
+
WHERE namespace = $1 AND app_id = $2
|
|
1473
|
+
AND metadata @> $4::jsonb
|
|
1474
|
+
ORDER BY priority ASC, created_at ASC
|
|
1475
|
+
LIMIT 1
|
|
1476
|
+
),
|
|
1477
|
+
claimed AS (
|
|
1478
|
+
UPDATE ${tbl}
|
|
1479
|
+
SET status = 'claimed',
|
|
1480
|
+
claimed_at = NOW(),
|
|
1481
|
+
claim_expires_at = NOW() + INTERVAL '${minutes} minutes',
|
|
1482
|
+
assigned_to = COALESCE($3, assigned_to),
|
|
1483
|
+
updated_at = NOW()
|
|
1484
|
+
WHERE namespace = $1 AND app_id = $2
|
|
1485
|
+
AND id = (SELECT id FROM found)
|
|
1486
|
+
AND status = 'pending'
|
|
1487
|
+
RETURNING *
|
|
1488
|
+
)
|
|
1489
|
+
SELECT c.*, f.id AS f_id
|
|
1490
|
+
FROM found f
|
|
1491
|
+
LEFT JOIN claimed c ON c.id = f.id`, [params.namespace, params.appId, params.assignee ?? null, containsJson]);
|
|
1492
|
+
if (result.rows.length === 0 || !result.rows[0].f_id) {
|
|
1493
|
+
return { ok: false, reason: 'not-found' };
|
|
1494
|
+
}
|
|
1495
|
+
if (!result.rows[0].id) {
|
|
1496
|
+
return { ok: false, reason: 'conflict' };
|
|
1497
|
+
}
|
|
1498
|
+
return { ok: true, entry: this.rowToSignalEntry(result.rows[0]) };
|
|
1499
|
+
}
|
|
1500
|
+
async releaseSignal(params) {
|
|
1501
|
+
const tbl = this.signalQueueTable();
|
|
1502
|
+
const result = await this.pgClient.query(`WITH found AS (
|
|
1503
|
+
SELECT id, status FROM ${tbl}
|
|
1504
|
+
WHERE namespace = $1 AND app_id = $2 AND id = $3
|
|
1505
|
+
),
|
|
1506
|
+
released AS (
|
|
1507
|
+
UPDATE ${tbl}
|
|
1508
|
+
SET status = 'pending',
|
|
1509
|
+
claimed_at = NULL,
|
|
1510
|
+
claim_expires_at = NULL,
|
|
1511
|
+
assigned_to = NULL,
|
|
1512
|
+
updated_at = NOW()
|
|
1513
|
+
WHERE namespace = $1 AND app_id = $2
|
|
1514
|
+
AND id = $3
|
|
1515
|
+
AND status = 'claimed'
|
|
1516
|
+
RETURNING id
|
|
1517
|
+
)
|
|
1518
|
+
SELECT r.id AS r_id, f.id AS f_id
|
|
1519
|
+
FROM found f
|
|
1520
|
+
LEFT JOIN released r ON r.id = f.id`, [params.namespace, params.appId, params.id]);
|
|
1521
|
+
if (result.rows.length === 0 || !result.rows[0].f_id) {
|
|
1522
|
+
return { ok: false, reason: 'not-found' };
|
|
1523
|
+
}
|
|
1524
|
+
if (!result.rows[0].r_id) {
|
|
1525
|
+
return { ok: false, reason: 'wrong-status' };
|
|
1526
|
+
}
|
|
1527
|
+
return { ok: true };
|
|
1528
|
+
}
|
|
1529
|
+
async resolveSignal(params) {
|
|
1530
|
+
const tbl = this.signalQueueTable();
|
|
1531
|
+
const result = await this.pgClient.query(`WITH found AS (
|
|
1532
|
+
SELECT id, status, signal_key, topic FROM ${tbl}
|
|
1533
|
+
WHERE namespace = $1 AND app_id = $2 AND id = $4
|
|
1534
|
+
),
|
|
1535
|
+
resolved AS (
|
|
1536
|
+
UPDATE ${tbl}
|
|
1537
|
+
SET status = 'resolved',
|
|
1538
|
+
resolved_at = NOW(),
|
|
1539
|
+
resolver_payload = $3::jsonb,
|
|
1540
|
+
updated_at = NOW()
|
|
1541
|
+
WHERE namespace = $1 AND app_id = $2
|
|
1542
|
+
AND id = $4
|
|
1543
|
+
AND status IN ('pending', 'claimed')
|
|
1544
|
+
RETURNING id, signal_key, topic
|
|
1545
|
+
)
|
|
1546
|
+
SELECT r.id AS r_id, r.signal_key, r.topic, f.id AS f_id
|
|
1547
|
+
FROM found f
|
|
1548
|
+
LEFT JOIN resolved r ON r.id = f.id`, [
|
|
1549
|
+
params.namespace,
|
|
1550
|
+
params.appId,
|
|
1551
|
+
params.resolverPayload ? JSON.stringify(params.resolverPayload) : null,
|
|
1552
|
+
params.id,
|
|
1553
|
+
]);
|
|
1554
|
+
if (result.rows.length === 0 || !result.rows[0].f_id) {
|
|
1555
|
+
return { ok: false, reason: 'not-found' };
|
|
1556
|
+
}
|
|
1557
|
+
if (!result.rows[0].r_id) {
|
|
1558
|
+
return { ok: false, reason: 'already-resolved' };
|
|
1559
|
+
}
|
|
1560
|
+
return {
|
|
1561
|
+
ok: true,
|
|
1562
|
+
signalKey: result.rows[0].signal_key,
|
|
1563
|
+
topic: result.rows[0].topic,
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
async resolveSignalByMetadata(params) {
|
|
1567
|
+
const tbl = this.signalQueueTable();
|
|
1568
|
+
const containsJson = JSON.stringify({ [params.key]: params.value });
|
|
1569
|
+
const result = await this.pgClient.query(`WITH found AS (
|
|
1570
|
+
SELECT id, signal_key, topic FROM ${tbl}
|
|
1571
|
+
WHERE namespace = $1 AND app_id = $2
|
|
1572
|
+
AND status IN ('pending', 'claimed')
|
|
1573
|
+
AND metadata @> $4::jsonb
|
|
1574
|
+
LIMIT 1
|
|
1575
|
+
),
|
|
1576
|
+
resolved AS (
|
|
1577
|
+
UPDATE ${tbl}
|
|
1578
|
+
SET status = 'resolved',
|
|
1579
|
+
resolved_at = NOW(),
|
|
1580
|
+
resolver_payload = $3::jsonb,
|
|
1581
|
+
updated_at = NOW()
|
|
1582
|
+
WHERE namespace = $1 AND app_id = $2
|
|
1583
|
+
AND id = (SELECT id FROM found)
|
|
1584
|
+
AND status IN ('pending', 'claimed')
|
|
1585
|
+
RETURNING id, signal_key, topic
|
|
1586
|
+
)
|
|
1587
|
+
SELECT r.id AS r_id, r.signal_key, r.topic, f.id AS f_id
|
|
1588
|
+
FROM found f
|
|
1589
|
+
LEFT JOIN resolved r ON r.id = f.id`, [
|
|
1590
|
+
params.namespace,
|
|
1591
|
+
params.appId,
|
|
1592
|
+
params.resolverPayload ? JSON.stringify(params.resolverPayload) : null,
|
|
1593
|
+
containsJson,
|
|
1594
|
+
]);
|
|
1595
|
+
if (result.rows.length === 0 || !result.rows[0].f_id) {
|
|
1596
|
+
return { ok: false, reason: 'not-found' };
|
|
1597
|
+
}
|
|
1598
|
+
return {
|
|
1599
|
+
ok: true,
|
|
1600
|
+
signalKey: result.rows[0].signal_key,
|
|
1601
|
+
topic: result.rows[0].topic,
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
async releaseExpiredSignals(params) {
|
|
1605
|
+
const tbl = this.signalQueueTable();
|
|
1606
|
+
const result = await this.pgClient.query(`UPDATE ${tbl}
|
|
1607
|
+
SET status = 'pending',
|
|
1608
|
+
claimed_at = NULL,
|
|
1609
|
+
claim_expires_at = NULL,
|
|
1610
|
+
updated_at = NOW()
|
|
1611
|
+
WHERE namespace = $1 AND app_id = $2
|
|
1612
|
+
AND status = 'claimed'
|
|
1613
|
+
AND claim_expires_at < NOW()`, [params.namespace, params.appId]);
|
|
1614
|
+
return result.rowCount ?? 0;
|
|
1615
|
+
}
|
|
1616
|
+
async listSignals(params) {
|
|
1617
|
+
const tbl = this.signalQueueTable();
|
|
1618
|
+
const conditions = ['namespace = $1', 'app_id = $2'];
|
|
1619
|
+
const values = [params.namespace, params.appId];
|
|
1620
|
+
let idx = 3;
|
|
1621
|
+
if (params.status) {
|
|
1622
|
+
conditions.push(`status = $${idx++}`);
|
|
1623
|
+
values.push(params.status);
|
|
1624
|
+
}
|
|
1625
|
+
if (params.role) {
|
|
1626
|
+
conditions.push(`role = $${idx++}`);
|
|
1627
|
+
values.push(params.role);
|
|
1628
|
+
}
|
|
1629
|
+
if (params.taskQueue) {
|
|
1630
|
+
conditions.push(`task_queue = $${idx++}`);
|
|
1631
|
+
values.push(params.taskQueue);
|
|
1632
|
+
}
|
|
1633
|
+
const where = conditions.join(' AND ');
|
|
1634
|
+
const limit = params.limit ?? 50;
|
|
1635
|
+
const offset = params.offset ?? 0;
|
|
1636
|
+
const result = await this.pgClient.query(`SELECT * FROM ${tbl} WHERE ${where} ORDER BY priority ASC, created_at ASC LIMIT $${idx++} OFFSET $${idx}`, [...values, limit, offset]);
|
|
1637
|
+
return result.rows.map(r => this.rowToSignalEntry(r));
|
|
1638
|
+
}
|
|
1639
|
+
async getSignal(params) {
|
|
1640
|
+
const tbl = this.signalQueueTable();
|
|
1641
|
+
const result = await this.pgClient.query(`SELECT * FROM ${tbl} WHERE namespace = $1 AND app_id = $2 AND id = $3`, [params.namespace, params.appId, params.id]);
|
|
1642
|
+
if (result.rows.length === 0)
|
|
1643
|
+
return null;
|
|
1644
|
+
return this.rowToSignalEntry(result.rows[0]);
|
|
1645
|
+
}
|
|
1646
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1371
1647
|
/**
|
|
1372
1648
|
* Parse a HotMesh-encoded value string.
|
|
1373
1649
|
* Values may be prefixed with `/s` (JSON), `/d` (number), `/t` or `/f` (boolean), `/n` (null).
|
|
@@ -201,6 +201,13 @@ async function ensureProcedures(client, schemaName) {
|
|
|
201
201
|
* statement-level form. Recreating a trigger takes an ACCESS EXCLUSIVE
|
|
202
202
|
* lock on the table, so only do it when the installed trigger is still
|
|
203
203
|
* row-level (tgtype bit 0 set); subsequent boots are a no-op.
|
|
204
|
+
*
|
|
205
|
+
* The function replacement and trigger swap MUST commit atomically:
|
|
206
|
+
* between them, the still-installed row-level trigger would invoke the
|
|
207
|
+
* statement-level function body, whose transition-table reference
|
|
208
|
+
* (new_rows) errors under FOR EACH ROW — failing every concurrent
|
|
209
|
+
* INSERT until the swap lands (or indefinitely, if the migrating
|
|
210
|
+
* process dies between the two statements).
|
|
204
211
|
*/
|
|
205
212
|
async function ensureStatementLevelTriggers(client, schemaName) {
|
|
206
213
|
const result = await client.query(`SELECT count(*) AS row_level
|
|
@@ -212,7 +219,15 @@ async function ensureStatementLevelTriggers(client, schemaName) {
|
|
|
212
219
|
AND t.tgname IN ('notify_engine_stream_insert', 'notify_worker_stream_insert')
|
|
213
220
|
AND (t.tgtype & 1) = 1`, [schemaName]);
|
|
214
221
|
if (parseInt(result.rows[0].row_level, 10) > 0) {
|
|
215
|
-
await
|
|
222
|
+
await client.query('BEGIN');
|
|
223
|
+
try {
|
|
224
|
+
await createNotificationTriggers(client, schemaName);
|
|
225
|
+
await client.query('COMMIT');
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
await client.query('ROLLBACK');
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
216
231
|
}
|
|
217
232
|
}
|
|
218
233
|
async function createTables(client, schemaName) {
|
package/build/types/index.d.ts
CHANGED
|
@@ -25,3 +25,4 @@ export { ReclaimedMessageType, RetryPolicy, RouterConfig, StreamCode, StreamConf
|
|
|
25
25
|
export { context, Context, Counter, Meter, metrics, propagation, SpanContext, Span, SpanStatus, SpanStatusCode, SpanKind, trace, Tracer, ValueType, } from './telemetry';
|
|
26
26
|
export { WorkListTaskType } from './task';
|
|
27
27
|
export { TransitionMatch, TransitionRule, Transitions } from './transition';
|
|
28
|
+
export { ClaimSignalByMetadataParams, ClaimSignalParams, ClaimSignalResult, ConditionQueueConfig, EnqueueSignalParams, ListSignalsParams, ReleaseSignalResult, ResolveSignalByMetadataParams, ResolveSignalParams, ResolveSignalResult, SignalQueueEntry, } from './signal';
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents a queued signal record in the hotmesh_signals table.
|
|
3
|
+
* Created atomically with workflow suspension when condition() is called
|
|
4
|
+
* with a queue configuration.
|
|
5
|
+
*/
|
|
6
|
+
export interface SignalQueueEntry {
|
|
7
|
+
id: string;
|
|
8
|
+
namespace: string;
|
|
9
|
+
appId: string;
|
|
10
|
+
signalKey: string;
|
|
11
|
+
workflowId: string;
|
|
12
|
+
jobId?: string;
|
|
13
|
+
topic?: string;
|
|
14
|
+
status: 'pending' | 'claimed' | 'resolved' | 'expired' | 'released';
|
|
15
|
+
role?: string;
|
|
16
|
+
type?: string;
|
|
17
|
+
subtype?: string;
|
|
18
|
+
priority: number;
|
|
19
|
+
description?: string;
|
|
20
|
+
taskQueue?: string;
|
|
21
|
+
workflowType?: string;
|
|
22
|
+
assignedTo?: string;
|
|
23
|
+
claimedAt?: Date;
|
|
24
|
+
claimExpiresAt?: Date;
|
|
25
|
+
resolvedAt?: Date;
|
|
26
|
+
resolverPayload?: Record<string, unknown>;
|
|
27
|
+
envelope?: Record<string, unknown>;
|
|
28
|
+
metadata?: Record<string, unknown>;
|
|
29
|
+
expiresAt?: Date;
|
|
30
|
+
createdAt: Date;
|
|
31
|
+
updatedAt: Date;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Optional queue configuration passed to condition() to create a
|
|
35
|
+
* richly-typed, indexed, claimable signal record alongside workflow suspension.
|
|
36
|
+
*
|
|
37
|
+
* metadata is GIN-indexed and used for claimByMetadata / resolveByMetadata queries.
|
|
38
|
+
* envelope is unindexed and intended for display context (form schemas, etc.).
|
|
39
|
+
*/
|
|
40
|
+
export interface ConditionQueueConfig {
|
|
41
|
+
role?: string;
|
|
42
|
+
type?: string;
|
|
43
|
+
subtype?: string;
|
|
44
|
+
priority?: number;
|
|
45
|
+
description?: string;
|
|
46
|
+
taskQueue?: string;
|
|
47
|
+
workflowType?: string;
|
|
48
|
+
assignedTo?: string;
|
|
49
|
+
metadata?: Record<string, unknown>;
|
|
50
|
+
envelope?: Record<string, unknown>;
|
|
51
|
+
durationMinutes?: number;
|
|
52
|
+
}
|
|
53
|
+
export interface EnqueueSignalParams {
|
|
54
|
+
namespace: string;
|
|
55
|
+
appId: string;
|
|
56
|
+
signalKey: string;
|
|
57
|
+
workflowId: string;
|
|
58
|
+
jobId?: string;
|
|
59
|
+
topic?: string;
|
|
60
|
+
role?: string;
|
|
61
|
+
type?: string;
|
|
62
|
+
subtype?: string;
|
|
63
|
+
priority?: number;
|
|
64
|
+
description?: string;
|
|
65
|
+
taskQueue?: string;
|
|
66
|
+
workflowType?: string;
|
|
67
|
+
assignedTo?: string;
|
|
68
|
+
metadata?: Record<string, unknown>;
|
|
69
|
+
envelope?: Record<string, unknown>;
|
|
70
|
+
expiresAt?: Date;
|
|
71
|
+
}
|
|
72
|
+
export interface ClaimSignalParams {
|
|
73
|
+
id: string;
|
|
74
|
+
assignee?: string;
|
|
75
|
+
durationMinutes?: number;
|
|
76
|
+
}
|
|
77
|
+
export interface ClaimSignalByMetadataParams {
|
|
78
|
+
key: string;
|
|
79
|
+
value: unknown;
|
|
80
|
+
assignee?: string;
|
|
81
|
+
durationMinutes?: number;
|
|
82
|
+
}
|
|
83
|
+
export interface ResolveSignalParams {
|
|
84
|
+
id: string;
|
|
85
|
+
resolverPayload?: Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
export interface ResolveSignalByMetadataParams {
|
|
88
|
+
key: string;
|
|
89
|
+
value: unknown;
|
|
90
|
+
resolverPayload?: Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
export interface ListSignalsParams {
|
|
93
|
+
status?: 'pending' | 'claimed' | 'resolved' | 'expired' | 'released';
|
|
94
|
+
role?: string;
|
|
95
|
+
taskQueue?: string;
|
|
96
|
+
limit?: number;
|
|
97
|
+
offset?: number;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Result of a claim operation (by ID or by metadata).
|
|
101
|
+
*
|
|
102
|
+
* - ok: true → signal was claimed; entry contains the full record
|
|
103
|
+
* - ok: false, reason: 'not-found' → no signal exists for the given id/metadata
|
|
104
|
+
* - ok: false, reason: 'conflict' → signal exists but was already claimed concurrently
|
|
105
|
+
*/
|
|
106
|
+
export type ClaimSignalResult = {
|
|
107
|
+
ok: true;
|
|
108
|
+
entry: SignalQueueEntry;
|
|
109
|
+
} | {
|
|
110
|
+
ok: false;
|
|
111
|
+
reason: 'not-found' | 'conflict';
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Result of a resolve operation (by ID or by metadata).
|
|
115
|
+
*
|
|
116
|
+
* - ok: true → signal marked resolved and workflow signal delivered
|
|
117
|
+
* - ok: false, reason: 'not-found' → no pending/claimed signal found
|
|
118
|
+
* - ok: false, reason: 'already-resolved' → signal exists but was already resolved (id-based only)
|
|
119
|
+
* - ok: false, reason: 'signal-failed' → DB record updated but workflow signal delivery failed;
|
|
120
|
+
* signalKey is provided so callers can retry delivery
|
|
121
|
+
*/
|
|
122
|
+
export type ResolveSignalResult = {
|
|
123
|
+
ok: true;
|
|
124
|
+
} | {
|
|
125
|
+
ok: false;
|
|
126
|
+
reason: 'not-found';
|
|
127
|
+
} | {
|
|
128
|
+
ok: false;
|
|
129
|
+
reason: 'already-resolved';
|
|
130
|
+
} | {
|
|
131
|
+
ok: false;
|
|
132
|
+
reason: 'signal-failed';
|
|
133
|
+
signalKey: string;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Result of a release operation.
|
|
137
|
+
*
|
|
138
|
+
* - ok: true → signal returned to pending
|
|
139
|
+
* - ok: false, reason: 'not-found' → no signal exists for this id
|
|
140
|
+
* - ok: false, reason: 'wrong-status' → signal exists but is not in 'claimed' status
|
|
141
|
+
*/
|
|
142
|
+
export type ReleaseSignalResult = {
|
|
143
|
+
ok: true;
|
|
144
|
+
} | {
|
|
145
|
+
ok: false;
|
|
146
|
+
reason: 'not-found' | 'wrong-status';
|
|
147
|
+
};
|