@hotmeshio/hotmesh 0.20.1 → 0.21.1

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 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:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.20.1",
3
+ "version": "0.21.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -82,6 +82,73 @@ 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
+ getBySignalKey: (signalKey: string, namespace?: string) => Promise<any>;
122
+ claim: (params: {
123
+ id: string;
124
+ namespace?: string;
125
+ assignee?: string;
126
+ durationMinutes?: number;
127
+ }) => Promise<import('../../types/signal').ClaimSignalResult>;
128
+ claimByMetadata: (params: {
129
+ key: string;
130
+ value: unknown;
131
+ namespace?: string;
132
+ assignee?: string;
133
+ durationMinutes?: number;
134
+ }) => Promise<import('../../types/signal').ClaimSignalResult>;
135
+ release: (params: {
136
+ id: string;
137
+ namespace?: string;
138
+ }) => Promise<import('../../types/signal').ReleaseSignalResult>;
139
+ resolve: (params: {
140
+ id: string;
141
+ namespace?: string;
142
+ resolverPayload?: Record<string, unknown>;
143
+ }) => Promise<import('../../types/signal').ResolveSignalResult>;
144
+ resolveByMetadata: (params: {
145
+ key: string;
146
+ value: unknown;
147
+ namespace?: string;
148
+ resolverPayload?: Record<string, unknown>;
149
+ }) => Promise<import('../../types/signal').ResolveSignalResult>;
150
+ releaseExpired: (namespace?: string) => Promise<number>;
151
+ };
85
152
  /**
86
153
  * Any router can be used to deploy and activate the HotMesh
87
154
  * distributed executable to the active quorum EXCEPT for
@@ -284,6 +284,165 @@ 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
+ getBySignalKey: async (signalKey, namespace) => {
339
+ const hotMesh = await this.getHotMeshClient(null, namespace);
340
+ const store = hotMesh.engine.store;
341
+ if (typeof store.getSignalBySignalKey !== 'function')
342
+ return null;
343
+ const ns = namespace ?? factory_1.APP_ID;
344
+ return store.getSignalBySignalKey({ namespace: ns, appId: store.appId, signalKey });
345
+ },
346
+ claim: async (params) => {
347
+ const hotMesh = await this.getHotMeshClient(null, params.namespace);
348
+ const store = hotMesh.engine.store;
349
+ if (typeof store.claimSignal !== 'function') {
350
+ return { ok: false, reason: 'not-found' };
351
+ }
352
+ const ns = params.namespace ?? factory_1.APP_ID;
353
+ return store.claimSignal({
354
+ namespace: ns,
355
+ appId: store.appId,
356
+ id: params.id,
357
+ assignee: params.assignee,
358
+ durationMinutes: params.durationMinutes,
359
+ });
360
+ },
361
+ claimByMetadata: async (params) => {
362
+ const hotMesh = await this.getHotMeshClient(null, params.namespace);
363
+ const store = hotMesh.engine.store;
364
+ if (typeof store.claimSignalByMetadata !== 'function') {
365
+ return { ok: false, reason: 'not-found' };
366
+ }
367
+ const ns = params.namespace ?? factory_1.APP_ID;
368
+ return store.claimSignalByMetadata({
369
+ namespace: ns,
370
+ appId: store.appId,
371
+ key: params.key,
372
+ value: params.value,
373
+ assignee: params.assignee,
374
+ durationMinutes: params.durationMinutes,
375
+ });
376
+ },
377
+ release: async (params) => {
378
+ const hotMesh = await this.getHotMeshClient(null, params.namespace);
379
+ const store = hotMesh.engine.store;
380
+ if (typeof store.releaseSignal !== 'function') {
381
+ return { ok: false, reason: 'not-found' };
382
+ }
383
+ const ns = params.namespace ?? factory_1.APP_ID;
384
+ return store.releaseSignal({
385
+ namespace: ns,
386
+ appId: store.appId,
387
+ id: params.id,
388
+ });
389
+ },
390
+ resolve: async (params) => {
391
+ const hotMesh = await this.getHotMeshClient(null, params.namespace);
392
+ const store = hotMesh.engine.store;
393
+ if (typeof store.resolveSignal !== 'function') {
394
+ return { ok: false, reason: 'not-found' };
395
+ }
396
+ const ns = params.namespace ?? factory_1.APP_ID;
397
+ const storeResult = await store.resolveSignal({
398
+ namespace: ns,
399
+ appId: store.appId,
400
+ id: params.id,
401
+ resolverPayload: params.resolverPayload,
402
+ });
403
+ if (!storeResult.ok)
404
+ return storeResult;
405
+ try {
406
+ await this.workflow.signal(storeResult.signalKey, params.resolverPayload ?? {}, params.namespace);
407
+ return { ok: true };
408
+ }
409
+ catch {
410
+ return { ok: false, reason: 'signal-failed', signalKey: storeResult.signalKey };
411
+ }
412
+ },
413
+ resolveByMetadata: async (params) => {
414
+ const hotMesh = await this.getHotMeshClient(null, params.namespace);
415
+ const store = hotMesh.engine.store;
416
+ if (typeof store.resolveSignalByMetadata !== 'function') {
417
+ return { ok: false, reason: 'not-found' };
418
+ }
419
+ const ns = params.namespace ?? factory_1.APP_ID;
420
+ const storeResult = await store.resolveSignalByMetadata({
421
+ namespace: ns,
422
+ appId: store.appId,
423
+ key: params.key,
424
+ value: params.value,
425
+ resolverPayload: params.resolverPayload,
426
+ });
427
+ if (!storeResult.ok)
428
+ return storeResult;
429
+ try {
430
+ await this.workflow.signal(storeResult.signalKey, params.resolverPayload ?? {}, params.namespace);
431
+ return { ok: true };
432
+ }
433
+ catch {
434
+ return { ok: false, reason: 'signal-failed', signalKey: storeResult.signalKey };
435
+ }
436
+ },
437
+ releaseExpired: async (namespace) => {
438
+ const hotMesh = await this.getHotMeshClient(null, namespace);
439
+ const store = hotMesh.engine.store;
440
+ if (typeof store.releaseExpiredSignals !== 'function')
441
+ return 0;
442
+ const ns = namespace ?? factory_1.APP_ID;
443
+ return store.releaseExpiredSignals({ namespace: ns, appId: store.appId });
444
+ },
445
+ };
287
446
  this.connection = config.connection;
288
447
  }
289
448
  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} [timeout] - Optional duration (e.g., '30s', '5m', '1h'). Returns `false` on timeout.
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, timeout?: string): Promise<T | false>;
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} [timeout] - Optional duration (e.g., '30s', '5m', '1h'). Returns `false` on timeout.
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, timeout) {
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,194 @@ 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
+ }>;
383
+ getSignalBySignalKey(params: {
384
+ namespace: string;
385
+ appId: string;
386
+ signalKey: string;
387
+ }): Promise<{
388
+ id: string;
389
+ namespace: string;
390
+ appId: string;
391
+ signalKey: string;
392
+ workflowId: string;
393
+ jobId: string;
394
+ topic: string;
395
+ status: "pending" | "claimed" | "resolved" | "expired" | "released";
396
+ role: string;
397
+ type: string;
398
+ subtype: string;
399
+ priority: number;
400
+ description: string;
401
+ taskQueue: string;
402
+ workflowType: string;
403
+ assignedTo: string;
404
+ claimedAt: Date;
405
+ claimExpiresAt: Date;
406
+ resolvedAt: Date;
407
+ resolverPayload: Record<string, unknown>;
408
+ envelope: Record<string, unknown>;
409
+ metadata: Record<string, unknown>;
410
+ expiresAt: Date;
411
+ createdAt: Date;
412
+ updatedAt: Date;
413
+ }>;
225
414
  /**
226
415
  * Parse a HotMesh-encoded value string.
227
416
  * Values may be prefixed with `/s` (JSON), `/d` (number), `/t` or `/f` (boolean), `/n` (null).
@@ -1368,6 +1368,289 @@ 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
+ async getSignalBySignalKey(params) {
1647
+ const tbl = this.signalQueueTable();
1648
+ const result = await this.pgClient.query(`SELECT * FROM ${tbl} WHERE namespace = $1 AND app_id = $2 AND signal_key = $3 LIMIT 1`, [params.namespace, params.appId, params.signalKey]);
1649
+ if (result.rows.length === 0)
1650
+ return null;
1651
+ return this.rowToSignalEntry(result.rows[0]);
1652
+ }
1653
+ // ─────────────────────────────────────────────────────────────────────────
1371
1654
  /**
1372
1655
  * Parse a HotMesh-encoded value string.
1373
1656
  * Values may be prefixed with `/s` (JSON), `/d` (number), `/t` or `/f` (boolean), `/n` (null).
@@ -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
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.20.1",
3
+ "version": "0.21.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",