@hotmeshio/hotmesh 0.21.1 → 0.22.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.
Files changed (32) hide show
  1. package/README.md +12 -129
  2. package/build/modules/utils.d.ts +2 -0
  3. package/build/modules/utils.js +9 -1
  4. package/build/package.json +8 -2
  5. package/build/services/activities/hook.d.ts +178 -58
  6. package/build/services/activities/hook.js +244 -58
  7. package/build/services/activities/trigger.js +5 -1
  8. package/build/services/durable/client.d.ts +273 -67
  9. package/build/services/durable/client.js +351 -126
  10. package/build/services/durable/index.d.ts +7 -3
  11. package/build/services/durable/index.js +6 -0
  12. package/build/services/durable/schemas/factory.js +40 -0
  13. package/build/services/durable/worker.js +5 -28
  14. package/build/services/durable/workflow/condition.d.ts +69 -37
  15. package/build/services/durable/workflow/condition.js +70 -39
  16. package/build/services/hotmesh/index.d.ts +31 -4
  17. package/build/services/hotmesh/index.js +31 -4
  18. package/build/services/store/index.d.ts +1 -1
  19. package/build/services/store/providers/postgres/kvsql.d.ts +1 -1
  20. package/build/services/store/providers/postgres/kvtables.js +83 -122
  21. package/build/services/store/providers/postgres/kvtypes/hash/basic.d.ts +1 -1
  22. package/build/services/store/providers/postgres/kvtypes/hash/basic.js +8 -8
  23. package/build/services/store/providers/postgres/kvtypes/hash/index.d.ts +1 -1
  24. package/build/services/store/providers/postgres/postgres.d.ts +51 -188
  25. package/build/services/store/providers/postgres/postgres.js +542 -285
  26. package/build/types/activity.d.ts +2 -0
  27. package/build/types/hmsh_escalations.d.ts +240 -0
  28. package/build/types/index.d.ts +1 -1
  29. package/build/types/provider.d.ts +2 -0
  30. package/package.json +9 -2
  31. package/build/types/signal.d.ts +0 -147
  32. /package/build/types/{signal.js → hmsh_escalations.js} +0 -0
@@ -806,45 +806,22 @@ 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
+ const pendingInterruption = interruptionRegistry[0];
834
810
  return withPatchMarkers({
835
811
  status: stream_1.StreamStatus.SUCCESS,
836
812
  code: enums_1.HMSH_CODE_DURABLE_WAIT,
837
813
  metadata: { ...data.metadata },
838
814
  data: {
839
815
  code: enums_1.HMSH_CODE_DURABLE_WAIT,
840
- signalId: interruptionRegistry[0].signalId,
816
+ signalId: pendingInterruption.signalId,
841
817
  index: execIndex,
842
- workflowDimension: interruptionRegistry[0].workflowDimension ||
818
+ workflowDimension: pendingInterruption.workflowDimension ||
843
819
  workflowDimension ||
844
820
  '',
845
- duration: interruptionRegistry[0].duration,
821
+ duration: pendingInterruption.duration,
846
822
  workflowId,
847
823
  originJobId: originJobId || workflowId,
824
+ queueConfig: pendingInterruption.queueConfig ?? null,
848
825
  },
849
826
  });
850
827
  }
@@ -1,5 +1,4 @@
1
- import type { ConditionQueueConfig } from '../../../types/signal';
2
- export type { ConditionQueueConfig };
1
+ import { ConditionQueueConfig } from '../../../types/hmsh_escalations';
3
2
  /**
4
3
  * Pauses the workflow until a signal with the given `signalId` is received.
5
4
  * The workflow suspends durably — it survives process restarts and will
@@ -7,64 +6,97 @@ export type { ConditionQueueConfig };
7
6
  *
8
7
  * `condition` is the **receive** side of the signal coordination pair.
9
8
  * The **send** side is `signal()`, which can be called from another
10
- * workflow, a hook function, or externally via `Durable.Client.workflow.signal()`.
9
+ * workflow, a hook function, or externally via `client.workflow.signal()`.
11
10
  *
12
11
  * On replay, `condition` returns the previously stored signal payload
13
- * immediately (no actual suspension occurs).
12
+ * immediately no actual suspension occurs.
14
13
  *
15
- * ## Examples
14
+ * ## Basic usage
16
15
  *
17
16
  * ```typescript
18
17
  * import { Durable } from '@hotmeshio/hotmesh';
19
18
  *
20
- * // Human-in-the-loop approval pattern
21
19
  * export async function approvalWorkflow(orderId: string): Promise<boolean> {
22
20
  * const { submitForReview } = Durable.workflow.proxyActivities<typeof activities>();
23
- *
24
21
  * await submitForReview(orderId);
25
22
  *
26
- * // Pause indefinitely until a human approves or rejects
23
+ * // Pause until a human approves or rejects
27
24
  * const decision = await Durable.workflow.condition<{ approved: boolean }>('approval');
28
- *
29
25
  * return decision.approved;
30
26
  * }
31
27
  *
32
- * // Later, from outside the workflow (e.g., an API handler):
28
+ * // From an API handler or another workflow:
33
29
  * await client.workflow.signal('approval', { approved: true });
34
30
  * ```
35
31
  *
32
+ * ## With timeout
33
+ *
34
+ * Pass a duration string as the second argument to set a deadline.
35
+ * `condition` returns `false` if the timeout fires before a signal arrives.
36
+ *
36
37
  * ```typescript
37
- * // Fan-in: wait for multiple signals in parallel
38
- * export async function gatherWorkflow(): Promise<[string, number]> {
39
- * const [name, score] = await Promise.all([
40
- * Durable.workflow.condition<string>('name-signal'),
41
- * Durable.workflow.condition<number>('score-signal'),
42
- * ]);
43
- * return [name, score];
44
- * }
38
+ * const decision = await Durable.workflow.condition<{ approved: boolean }>(
39
+ * 'approval',
40
+ * '72h', // give reviewers 72 hours; returns false on timeout
41
+ * );
42
+ * if (decision === false) return 'auto-rejected-timeout';
43
+ * return decision.approved ? 'approved' : 'rejected';
45
44
  * ```
46
45
  *
46
+ * ## With escalation queue config
47
+ *
48
+ * Pass a {@link ConditionQueueConfig} as the second argument to surface the
49
+ * pause as a claimable row in `public.hmsh_escalations`. The INSERT is
50
+ * committed atomically with the workflow checkpoint — one write, no
51
+ * enrichment step, no secondary round-trip.
52
+ *
47
53
  * ```typescript
48
- * // Paired with hook: spawn work and wait for the result
49
- * export async function orchestrator(input: string): Promise<string> {
50
- * const signalId = `result-${Durable.workflow.random()}`;
51
- *
52
- * // Spawn a hook that will signal back when done
53
- * await Durable.workflow.hook({
54
- * taskQueue: 'processors',
55
- * workflowName: 'processItem',
56
- * args: [input, signalId],
57
- * });
58
- *
59
- * // Wait for the hook to signal completion
60
- * return await Durable.workflow.condition<string>(signalId);
61
- * }
54
+ * const decision = await Durable.workflow.condition<{ approved: boolean }>(
55
+ * 'manager-approval',
56
+ * {
57
+ * role: 'manager',
58
+ * type: 'order-approval',
59
+ * subtype: 'regional',
60
+ * priority: 2,
61
+ * description: 'Approve or reject the regional order',
62
+ * metadata: { orderId, region },
63
+ * envelope: { instructions: 'Review the attached order' },
64
+ * },
65
+ * );
66
+ *
67
+ * // Elsewhere: list, claim, then resolve (resumes the workflow)
68
+ * const [item] = await client.escalations.list({ role: 'manager', status: 'pending' });
69
+ * await client.escalations.claim({ id: item.id, assignee: 'alice@company.com' });
70
+ * await client.escalations.resolve({ id: item.id, resolverPayload: { approved: true } });
71
+ * ```
72
+ *
73
+ * ## Fan-in: wait for multiple signals in parallel
74
+ *
75
+ * ```typescript
76
+ * const [name, score] = await Promise.all([
77
+ * Durable.workflow.condition<string>('name-signal'),
78
+ * Durable.workflow.condition<number>('score-signal'),
79
+ * ]);
80
+ * ```
81
+ *
82
+ * ## Paired with hook: spawn work, wait for its signal
83
+ *
84
+ * ```typescript
85
+ * const signalId = `result-${Durable.workflow.random()}`;
86
+ * await Durable.workflow.hook({
87
+ * taskQueue: 'processors',
88
+ * workflowName: 'processItem',
89
+ * args: [input, signalId],
90
+ * });
91
+ * return await Durable.workflow.condition<string>(signalId);
62
92
  * ```
63
93
  *
64
94
  * @template T - The type of data expected in the signal payload.
65
- * @param {string} signalId - A unique signal identifier shared by the sender and receiver.
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.
68
- * @returns {Promise<T | false>} The signal data, or `false` if the timeout expired first.
95
+ * @param signalId - A unique signal identifier shared by the sender and receiver.
96
+ * @param timeoutOrConfig - Optional timeout string (e.g. `'30s'`, `'24h'`) OR a
97
+ * {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
98
+ * atomically at suspension time. Cannot specify both; use the config object's
99
+ * `expiresAt` field for deadline enforcement when an escalation is involved.
100
+ * @returns The signal payload, or `false` if a timeout string was given and it expired.
69
101
  */
70
- export declare function condition<T>(signalId: string, timeoutOrConfig?: string | ConditionQueueConfig, queueConfig?: ConditionQueueConfig): Promise<T | false>;
102
+ export declare function condition<T>(signalId: string, timeoutOrConfig?: string | ConditionQueueConfig): Promise<T | false>;
@@ -11,71 +11,102 @@ const didRun_1 = require("./didRun");
11
11
  *
12
12
  * `condition` is the **receive** side of the signal coordination pair.
13
13
  * The **send** side is `signal()`, which can be called from another
14
- * workflow, a hook function, or externally via `Durable.Client.workflow.signal()`.
14
+ * workflow, a hook function, or externally via `client.workflow.signal()`.
15
15
  *
16
16
  * On replay, `condition` returns the previously stored signal payload
17
- * immediately (no actual suspension occurs).
17
+ * immediately no actual suspension occurs.
18
18
  *
19
- * ## Examples
19
+ * ## Basic usage
20
20
  *
21
21
  * ```typescript
22
22
  * import { Durable } from '@hotmeshio/hotmesh';
23
23
  *
24
- * // Human-in-the-loop approval pattern
25
24
  * export async function approvalWorkflow(orderId: string): Promise<boolean> {
26
25
  * const { submitForReview } = Durable.workflow.proxyActivities<typeof activities>();
27
- *
28
26
  * await submitForReview(orderId);
29
27
  *
30
- * // Pause indefinitely until a human approves or rejects
28
+ * // Pause until a human approves or rejects
31
29
  * const decision = await Durable.workflow.condition<{ approved: boolean }>('approval');
32
- *
33
30
  * return decision.approved;
34
31
  * }
35
32
  *
36
- * // Later, from outside the workflow (e.g., an API handler):
33
+ * // From an API handler or another workflow:
37
34
  * await client.workflow.signal('approval', { approved: true });
38
35
  * ```
39
36
  *
37
+ * ## With timeout
38
+ *
39
+ * Pass a duration string as the second argument to set a deadline.
40
+ * `condition` returns `false` if the timeout fires before a signal arrives.
41
+ *
40
42
  * ```typescript
41
- * // Fan-in: wait for multiple signals in parallel
42
- * export async function gatherWorkflow(): Promise<[string, number]> {
43
- * const [name, score] = await Promise.all([
44
- * Durable.workflow.condition<string>('name-signal'),
45
- * Durable.workflow.condition<number>('score-signal'),
46
- * ]);
47
- * return [name, score];
48
- * }
43
+ * const decision = await Durable.workflow.condition<{ approved: boolean }>(
44
+ * 'approval',
45
+ * '72h', // give reviewers 72 hours; returns false on timeout
46
+ * );
47
+ * if (decision === false) return 'auto-rejected-timeout';
48
+ * return decision.approved ? 'approved' : 'rejected';
49
49
  * ```
50
50
  *
51
+ * ## With escalation queue config
52
+ *
53
+ * Pass a {@link ConditionQueueConfig} as the second argument to surface the
54
+ * pause as a claimable row in `public.hmsh_escalations`. The INSERT is
55
+ * committed atomically with the workflow checkpoint — one write, no
56
+ * enrichment step, no secondary round-trip.
57
+ *
51
58
  * ```typescript
52
- * // Paired with hook: spawn work and wait for the result
53
- * export async function orchestrator(input: string): Promise<string> {
54
- * const signalId = `result-${Durable.workflow.random()}`;
55
- *
56
- * // Spawn a hook that will signal back when done
57
- * await Durable.workflow.hook({
58
- * taskQueue: 'processors',
59
- * workflowName: 'processItem',
60
- * args: [input, signalId],
61
- * });
62
- *
63
- * // Wait for the hook to signal completion
64
- * return await Durable.workflow.condition<string>(signalId);
65
- * }
59
+ * const decision = await Durable.workflow.condition<{ approved: boolean }>(
60
+ * 'manager-approval',
61
+ * {
62
+ * role: 'manager',
63
+ * type: 'order-approval',
64
+ * subtype: 'regional',
65
+ * priority: 2,
66
+ * description: 'Approve or reject the regional order',
67
+ * metadata: { orderId, region },
68
+ * envelope: { instructions: 'Review the attached order' },
69
+ * },
70
+ * );
71
+ *
72
+ * // Elsewhere: list, claim, then resolve (resumes the workflow)
73
+ * const [item] = await client.escalations.list({ role: 'manager', status: 'pending' });
74
+ * await client.escalations.claim({ id: item.id, assignee: 'alice@company.com' });
75
+ * await client.escalations.resolve({ id: item.id, resolverPayload: { approved: true } });
76
+ * ```
77
+ *
78
+ * ## Fan-in: wait for multiple signals in parallel
79
+ *
80
+ * ```typescript
81
+ * const [name, score] = await Promise.all([
82
+ * Durable.workflow.condition<string>('name-signal'),
83
+ * Durable.workflow.condition<number>('score-signal'),
84
+ * ]);
85
+ * ```
86
+ *
87
+ * ## Paired with hook: spawn work, wait for its signal
88
+ *
89
+ * ```typescript
90
+ * const signalId = `result-${Durable.workflow.random()}`;
91
+ * await Durable.workflow.hook({
92
+ * taskQueue: 'processors',
93
+ * workflowName: 'processItem',
94
+ * args: [input, signalId],
95
+ * });
96
+ * return await Durable.workflow.condition<string>(signalId);
66
97
  * ```
67
98
  *
68
99
  * @template T - The type of data expected in the signal payload.
69
- * @param {string} signalId - A unique signal identifier shared by the sender and receiver.
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.
72
- * @returns {Promise<T | false>} The signal data, or `false` if the timeout expired first.
100
+ * @param signalId - A unique signal identifier shared by the sender and receiver.
101
+ * @param timeoutOrConfig - Optional timeout string (e.g. `'30s'`, `'24h'`) OR a
102
+ * {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
103
+ * atomically at suspension time. Cannot specify both; use the config object's
104
+ * `expiresAt` field for deadline enforcement when an escalation is involved.
105
+ * @returns The signal payload, or `false` if a timeout string was given and it expired.
73
106
  */
74
- async function condition(signalId, timeoutOrConfig, queueConfig) {
107
+ async function condition(signalId, timeoutOrConfig) {
75
108
  const timeout = typeof timeoutOrConfig === 'string' ? timeoutOrConfig : undefined;
76
- const resolvedQueueConfig = typeof timeoutOrConfig === 'object' && timeoutOrConfig !== null
77
- ? timeoutOrConfig
78
- : queueConfig;
109
+ const queueConfig = timeoutOrConfig && typeof timeoutOrConfig === 'object' ? timeoutOrConfig : undefined;
79
110
  const [didRunAlready, execIndex, result] = await (0, didRun_1.didRun)('wait');
80
111
  (0, cancellationScope_1.checkCancellation)();
81
112
  if (didRunAlready) {
@@ -122,7 +153,7 @@ async function condition(signalId, timeoutOrConfig, queueConfig) {
122
153
  type: 'DurableWaitForError',
123
154
  code: common_1.HMSH_CODE_DURABLE_WAIT,
124
155
  ...(timeout ? { duration: (0, common_1.s)(timeout) } : {}),
125
- ...(resolvedQueueConfig ? { queueConfig: resolvedQueueConfig } : {}),
156
+ ...(queueConfig ? { queueConfig } : {}),
126
157
  };
127
158
  interruptionRegistry.push(interruptionMessage);
128
159
  await (0, common_1.sleepImmediate)();
@@ -320,11 +320,38 @@ declare class HotMesh {
320
320
  */
321
321
  scrub(jobId: string): Promise<void>;
322
322
  /**
323
- * Sends a signal to a paused workflow, delivering data and resuming
324
- * execution. Pairs with `condition()` in the Durable workflow API.
323
+ * Sends a signal to a paused workflow, resuming its execution with the
324
+ * provided data. This is the low-level primitive used by both the Durable
325
+ * `client.workflow.signal()` wrapper and `client.escalations.resolve()`.
325
326
  *
326
- * @param topic - The signal topic.
327
- * @param data - Signal payload.
327
+ * The signal is matched to the waiting activity by the `id` field inside
328
+ * `data` it must equal the value used in the hook rule's
329
+ * `conditions.match[0].expected` expression (typically the job ID).
330
+ *
331
+ * **YAML DAG hook example** — signal a workflow paused at a webhook:
332
+ *
333
+ * ```typescript
334
+ * // The hook activity is waiting on topic 'order.approval'
335
+ * // The hook rule expects: actual '{$self.hook.data.id}' === '{t1.output.data.id}'
336
+ * await hotMesh.signal('order.approval', { id: jobId, approved: true });
337
+ * ```
338
+ *
339
+ * **Durable workflow** — use `client.workflow.signal()` instead, which
340
+ * resolves the topic internally:
341
+ *
342
+ * ```typescript
343
+ * await client.workflow.signal('manager-approval', { approved: true });
344
+ * ```
345
+ *
346
+ * **With escalation** — use `client.escalations.resolve()` to atomically
347
+ * deliver the signal and mark the escalation row resolved:
348
+ *
349
+ * ```typescript
350
+ * await client.escalations.resolve({ id: escalationId, resolverPayload: { approved: true } });
351
+ * ```
352
+ *
353
+ * @param topic - The signal topic (must match a deployed hook rule).
354
+ * @param data - Signal payload. Must contain `id` matching the hook rule's expected value.
328
355
  */
329
356
  signal(topic: string, data: JobData, status?: StreamStatus, code?: StreamCode): Promise<string>;
330
357
  /** @private */
@@ -428,11 +428,38 @@ class HotMesh {
428
428
  return Jobs.scrub(this, jobId);
429
429
  }
430
430
  /**
431
- * Sends a signal to a paused workflow, delivering data and resuming
432
- * execution. Pairs with `condition()` in the Durable workflow API.
431
+ * Sends a signal to a paused workflow, resuming its execution with the
432
+ * provided data. This is the low-level primitive used by both the Durable
433
+ * `client.workflow.signal()` wrapper and `client.escalations.resolve()`.
433
434
  *
434
- * @param topic - The signal topic.
435
- * @param data - Signal payload.
435
+ * The signal is matched to the waiting activity by the `id` field inside
436
+ * `data` it must equal the value used in the hook rule's
437
+ * `conditions.match[0].expected` expression (typically the job ID).
438
+ *
439
+ * **YAML DAG hook example** — signal a workflow paused at a webhook:
440
+ *
441
+ * ```typescript
442
+ * // The hook activity is waiting on topic 'order.approval'
443
+ * // The hook rule expects: actual '{$self.hook.data.id}' === '{t1.output.data.id}'
444
+ * await hotMesh.signal('order.approval', { id: jobId, approved: true });
445
+ * ```
446
+ *
447
+ * **Durable workflow** — use `client.workflow.signal()` instead, which
448
+ * resolves the topic internally:
449
+ *
450
+ * ```typescript
451
+ * await client.workflow.signal('manager-approval', { approved: true });
452
+ * ```
453
+ *
454
+ * **With escalation** — use `client.escalations.resolve()` to atomically
455
+ * deliver the signal and mark the escalation row resolved:
456
+ *
457
+ * ```typescript
458
+ * await client.escalations.resolve({ id: escalationId, resolverPayload: { approved: true } });
459
+ * ```
460
+ *
461
+ * @param topic - The signal topic (must match a deployed hook rule).
462
+ * @param data - Signal payload. Must contain `id` matching the hook rule's expected value.
436
463
  */
437
464
  async signal(topic, data, status, code) {
438
465
  return Jobs.signal(this, topic, data, status, code);
@@ -45,7 +45,7 @@ declare abstract class StoreService<Provider extends ProviderClient, Transaction
45
45
  jobId: string, appId: string, guidField: string, //
46
46
  guidWeight: number, transaction?: ProviderTransaction): Promise<any>;
47
47
  abstract getStatus(jobId: string, appId: string): Promise<number>;
48
- abstract setStateNX(jobId: string, appId: string, status?: number, entity?: string, transaction?: ProviderTransaction): Promise<boolean>;
48
+ abstract setStateNX(jobId: string, appId: string, status?: number, entity?: string, transaction?: ProviderTransaction, originId?: string, parentId?: string): Promise<boolean>;
49
49
  abstract setState(state: StringAnyType, status: number | null, jobId: string, symbolNames: string[], dIds: StringStringType, transaction?: TransactionProvider): Promise<string>;
50
50
  abstract getQueryState(jobId: string, fields: string[]): Promise<StringAnyType>;
51
51
  abstract getState(jobId: string, consumes: Consumes, dIds: StringStringType): Promise<[StringAnyType, number] | undefined>;
@@ -59,7 +59,7 @@ export declare class KVSQL {
59
59
  setnxex: (key: string, value: string, delay: number, multi?: ProviderTransaction) => Promise<boolean>;
60
60
  hset: (key: string, fields: Record<string, string>, options?: import("./kvtypes/hash/index").HSetOptions, multi?: any) => Promise<any>;
61
61
  _hset: (key: string, fields: Record<string, string>, options?: import("./kvtypes/hash/index").HSetOptions) => import("./kvtypes/hash/types").SqlResult;
62
- hsetnx: (key: string, field: string, value: string, multi?: ProviderTransaction, entity?: string) => Promise<number>;
62
+ hsetnx: (key: string, field: string, value: string, multi?: ProviderTransaction, entity?: string, originId?: string, parentId?: string) => Promise<number>;
63
63
  hget: (key: string, field: string, multi?: ProviderTransaction) => Promise<string>;
64
64
  _hget: (key: string, field: string) => import("./kvtypes/hash/types").SqlResult;
65
65
  hdel: (key: string, fields: string[], multi?: unknown) => Promise<number>;