@hotmeshio/hotmesh 0.21.0 → 0.22.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.
Files changed (30) hide show
  1. package/README.md +12 -129
  2. package/build/modules/utils.js +3 -0
  3. package/build/package.json +2 -1
  4. package/build/services/activities/hook.d.ts +178 -58
  5. package/build/services/activities/hook.js +244 -58
  6. package/build/services/activities/trigger.js +5 -1
  7. package/build/services/durable/client.d.ts +238 -66
  8. package/build/services/durable/client.js +309 -125
  9. package/build/services/durable/index.d.ts +0 -2
  10. package/build/services/durable/schemas/factory.js +40 -0
  11. package/build/services/durable/worker.js +5 -28
  12. package/build/services/durable/workflow/condition.d.ts +69 -37
  13. package/build/services/durable/workflow/condition.js +70 -39
  14. package/build/services/hotmesh/index.d.ts +31 -4
  15. package/build/services/hotmesh/index.js +31 -4
  16. package/build/services/store/index.d.ts +1 -1
  17. package/build/services/store/providers/postgres/kvsql.d.ts +1 -1
  18. package/build/services/store/providers/postgres/kvtables.js +83 -122
  19. package/build/services/store/providers/postgres/kvtypes/hash/basic.d.ts +1 -1
  20. package/build/services/store/providers/postgres/kvtypes/hash/basic.js +8 -8
  21. package/build/services/store/providers/postgres/kvtypes/hash/index.d.ts +1 -1
  22. package/build/services/store/providers/postgres/postgres.d.ts +44 -157
  23. package/build/services/store/providers/postgres/postgres.js +480 -278
  24. package/build/types/activity.d.ts +2 -0
  25. package/build/types/hmsh_escalations.d.ts +212 -0
  26. package/build/types/index.d.ts +1 -1
  27. package/build/types/provider.d.ts +2 -0
  28. package/package.json +2 -1
  29. package/build/types/signal.d.ts +0 -147
  30. /package/build/types/{signal.js → hmsh_escalations.js} +0 -0
@@ -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>;
@@ -166,65 +166,6 @@ 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
- }
228
169
  },
229
170
  async createTables(client, appName) {
230
171
  try {
@@ -259,6 +200,87 @@ const KVTables = (context) => ({
259
200
  deployed_at TIMESTAMPTZ DEFAULT NOW(),
260
201
  PRIMARY KEY (app_id, version)
261
202
  );
203
+ `);
204
+ await client.query(`
205
+ CREATE TABLE IF NOT EXISTS public.hmsh_escalations (
206
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
207
+ namespace TEXT NOT NULL,
208
+ app_id TEXT NOT NULL,
209
+ signal_key TEXT,
210
+ topic TEXT,
211
+ workflow_id TEXT,
212
+ task_queue TEXT,
213
+ workflow_type TEXT,
214
+ type TEXT,
215
+ subtype TEXT,
216
+ entity TEXT,
217
+ description TEXT,
218
+ role TEXT,
219
+ status TEXT NOT NULL DEFAULT 'pending',
220
+ priority INT NOT NULL DEFAULT 5,
221
+ assigned_to TEXT,
222
+ assigned_until TIMESTAMPTZ,
223
+ claimed_at TIMESTAMPTZ,
224
+ claim_expires_at TIMESTAMPTZ,
225
+ resolved_at TIMESTAMPTZ,
226
+ escalation_payload JSONB,
227
+ resolver_payload JSONB,
228
+ envelope JSONB,
229
+ metadata JSONB,
230
+ origin_id TEXT,
231
+ parent_id TEXT,
232
+ initiated_by TEXT,
233
+ created_by TEXT,
234
+ milestones JSONB NOT NULL DEFAULT '[]',
235
+ trace_id TEXT,
236
+ span_id TEXT,
237
+ expires_at TIMESTAMPTZ,
238
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
239
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
240
+ );
241
+ `);
242
+ await client.query(`
243
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_hmsh_esc_signal_key
244
+ ON public.hmsh_escalations(namespace, app_id, signal_key)
245
+ WHERE signal_key IS NOT NULL;
246
+ `);
247
+ await client.query(`
248
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_available
249
+ ON public.hmsh_escalations(namespace, app_id, role, priority ASC, created_at ASC)
250
+ WHERE status = 'pending';
251
+ `);
252
+ await client.query(`
253
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_available_expiry
254
+ ON public.hmsh_escalations(namespace, app_id, role, assigned_until, created_at DESC);
255
+ `);
256
+ await client.query(`
257
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_assigned
258
+ ON public.hmsh_escalations(assigned_to, assigned_until, created_at DESC)
259
+ WHERE status = 'claimed' AND assigned_to IS NOT NULL;
260
+ `);
261
+ await client.query(`
262
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_claim_expiry
263
+ ON public.hmsh_escalations(claim_expires_at)
264
+ WHERE status = 'claimed';
265
+ `);
266
+ await client.query(`
267
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_entity
268
+ ON public.hmsh_escalations(namespace, app_id, entity, created_at DESC)
269
+ WHERE entity IS NOT NULL;
270
+ `);
271
+ await client.query(`
272
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_workflow
273
+ ON public.hmsh_escalations(workflow_id)
274
+ WHERE workflow_id IS NOT NULL;
275
+ `);
276
+ await client.query(`
277
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_origin
278
+ ON public.hmsh_escalations(origin_id)
279
+ WHERE origin_id IS NOT NULL;
280
+ `);
281
+ await client.query(`
282
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_metadata
283
+ ON public.hmsh_escalations USING GIN(metadata jsonb_path_ops);
262
284
  `);
263
285
  break;
264
286
  case 'relational_connection':
@@ -320,6 +342,8 @@ const KVTables = (context) => ({
320
342
  id UUID DEFAULT gen_random_uuid(),
321
343
  key TEXT NOT NULL,
322
344
  entity TEXT,
345
+ origin_id TEXT,
346
+ parent_id TEXT,
323
347
  status INTEGER NOT NULL,
324
348
  context JSONB DEFAULT '{}',
325
349
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
@@ -535,64 +559,6 @@ const KVTables = (context) => ({
535
559
  ON ${fullTableName} (key, score, member);
536
560
  `);
537
561
  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
- }
596
562
  default:
597
563
  context.logger.warn(`Unknown table type for ${tableDef.name}`);
598
564
  break;
@@ -710,11 +676,6 @@ const KVTables = (context) => ({
710
676
  name: 'signal_registry',
711
677
  type: 'string',
712
678
  },
713
- {
714
- schema: schemaName,
715
- name: 'hotmesh_signals',
716
- type: 'signal_queue',
717
- },
718
679
  ];
719
680
  return tableDefinitions;
720
681
  },
@@ -1,6 +1,6 @@
1
1
  import { HashContext, SqlResult, HSetOptions, ProviderTransaction } from './types';
2
2
  export declare function createBasicOperations(context: HashContext['context']): {
3
- hsetnx(key: string, field: string, value: string, multi?: ProviderTransaction, entity?: string): Promise<number>;
3
+ hsetnx(key: string, field: string, value: string, multi?: ProviderTransaction, entity?: string, originId?: string, parentId?: string): Promise<number>;
4
4
  hset(key: string, fields: Record<string, string>, options?: HSetOptions, multi?: ProviderTransaction): Promise<number | any>;
5
5
  hget(key: string, field: string, multi?: ProviderTransaction): Promise<string | null>;
6
6
  hdel(key: string, fields: string[], multi?: unknown): Promise<number>;