@hotmeshio/hotmesh 0.22.7 → 0.23.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.22.7",
3
+ "version": "0.23.0",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -167,7 +167,7 @@ class ClientService {
167
167
  pending: options?.pending,
168
168
  entity: options?.entity,
169
169
  });
170
- return new handle_1.WorkflowHandleService(hotMeshClient, workflowTopic, jobId);
170
+ return new handle_1.WorkflowHandleService(hotMeshClient, workflowTopic, jobId, this.escalations);
171
171
  },
172
172
  /**
173
173
  * Sends a message payload to a running workflow that is paused and awaiting the signal.
@@ -267,7 +267,7 @@ class ClientService {
267
267
  getHandle: async (taskQueue, workflowName, workflowId, namespace) => {
268
268
  const workflowTopic = `${taskQueue}-${workflowName}`;
269
269
  const hotMeshClient = await this.getHotMeshClient(taskQueue, namespace);
270
- return new handle_1.WorkflowHandleService(hotMeshClient, workflowTopic, workflowId);
270
+ return new handle_1.WorkflowHandleService(hotMeshClient, workflowTopic, workflowId, this.escalations);
271
271
  },
272
272
  /**
273
273
  * Provides direct access to the SEARCH backend
@@ -285,6 +285,14 @@ class ExporterService {
285
285
  if (options.enrich_inputs) {
286
286
  await this.enrichExecutionInputs(execution, jobId);
287
287
  }
288
+ // Opt-in upward lineage pointer — one indexed lookup; surfaces the real
289
+ // spawning parent (not the collator $C job) and the root ancestor.
290
+ if (options.include_lineage && this.store.getJobLineage) {
291
+ const jobKey = `hmsh:${this.appId}:j:${jobId}`;
292
+ const lineage = await this.store.getJobLineage(jobKey);
293
+ execution.parent_workflow_id = lineage?.parent_id ?? null;
294
+ execution.origin_id = lineage?.origin_id ?? null;
295
+ }
288
296
  return execution;
289
297
  }
290
298
  /**
@@ -3,6 +3,7 @@ import { DurableJobExport, ExportOptions, ExecutionExportOptions, WorkflowExecut
3
3
  import { JobInterruptOptions } from '../../types/job';
4
4
  import { StreamError } from '../../types/stream';
5
5
  import { ExporterService } from './exporter';
6
+ import { EscalationClientService } from '../escalations/client';
6
7
  /**
7
8
  * Handle to a running or completed workflow execution. Returned by
8
9
  * `client.workflow.start()` and `client.workflow.getHandle()`.
@@ -32,10 +33,12 @@ export declare class WorkflowHandleService {
32
33
  hotMesh: HotMesh;
33
34
  workflowTopic: string;
34
35
  workflowId: string;
36
+ /** @private */
37
+ escalationClient?: EscalationClientService;
35
38
  /**
36
39
  * @private
37
40
  */
38
- constructor(hotMesh: HotMesh, workflowTopic: string, workflowId: string);
41
+ constructor(hotMesh: HotMesh, workflowTopic: string, workflowId: string, escalationClient?: EscalationClientService);
39
42
  /**
40
43
  * Export the raw workflow state as a {@link DurableJobExport} with five sections:
41
44
  *
@@ -126,6 +129,12 @@ export declare class WorkflowHandleService {
126
129
  * subscribers are notified, and the job hash is expired. Unlike
127
130
  * {@link cancel}, this does **not** give the workflow a chance to
128
131
  * run cleanup code.
132
+ *
133
+ * Any pending escalations for this workflow are cancelled in the same
134
+ * Postgres transaction that decrements the job semaphore — one atomic
135
+ * write, no TOCTOU. A `system.escalation.*.cancelled` event is emitted
136
+ * locally for each cancelled row via the configured `events.publish`
137
+ * sink — instance-local only, never broadcast.
129
138
  */
130
139
  terminate(options?: JobInterruptOptions): Promise<string>;
131
140
  /**
@@ -27,10 +27,11 @@ class WorkflowHandleService {
27
27
  /**
28
28
  * @private
29
29
  */
30
- constructor(hotMesh, workflowTopic, workflowId) {
30
+ constructor(hotMesh, workflowTopic, workflowId, escalationClient) {
31
31
  this.workflowTopic = workflowTopic;
32
32
  this.workflowId = workflowId;
33
33
  this.hotMesh = hotMesh;
34
+ this.escalationClient = escalationClient;
34
35
  this.exporter = new exporter_1.ExporterService(this.hotMesh.appId, this.hotMesh.engine.store, this.hotMesh.engine.logger);
35
36
  }
36
37
  /**
@@ -157,9 +158,23 @@ class WorkflowHandleService {
157
158
  * subscribers are notified, and the job hash is expired. Unlike
158
159
  * {@link cancel}, this does **not** give the workflow a chance to
159
160
  * run cleanup code.
161
+ *
162
+ * Any pending escalations for this workflow are cancelled in the same
163
+ * Postgres transaction that decrements the job semaphore — one atomic
164
+ * write, no TOCTOU. A `system.escalation.*.cancelled` event is emitted
165
+ * locally for each cancelled row via the configured `events.publish`
166
+ * sink — instance-local only, never broadcast.
160
167
  */
161
168
  async terminate(options) {
162
- return await this.hotMesh.interrupt(`${this.hotMesh.appId}.execute`, this.workflowId, options);
169
+ let cancelledEntries = [];
170
+ const result = await this.hotMesh.interrupt(`${this.hotMesh.appId}.execute`, this.workflowId, {
171
+ ...options,
172
+ onEscalationsCancelled: (entries) => { cancelledEntries = entries; },
173
+ });
174
+ if (this.escalationClient && cancelledEntries.length > 0) {
175
+ this.escalationClient.emitCancelledBatch(cancelledEntries);
176
+ }
177
+ return result;
163
178
  }
164
179
  /**
165
180
  * Requests cooperative cancellation of the workflow. Unlike
@@ -1,4 +1,4 @@
1
- declare const APP_VERSION = "16";
1
+ declare const APP_VERSION = "17";
2
2
  declare const APP_ID = "durable";
3
3
  /**
4
4
  * returns a new durable workflow schema
@@ -6,8 +6,9 @@ exports.APP_ID = exports.APP_VERSION = exports.getWorkflowYAML = void 0;
6
6
  // schema upgrade and hot-swap to it (see WorkerService.activateWorkflow and
7
7
  // ClientService.deployAndActivate). Changing the YAML without a bump leaves
8
8
  // every already-deployed database on the old schema forever. Numeric string —
9
- // compared with `Number()` for ordering. (v16: condition() escalation hook.)
10
- const APP_VERSION = '16';
9
+ // compared with `Number()` for ordering. (v17: collator_waiter escalation block —
10
+ // escalation-bearing condition() inside Promise.all writes its hmsh_escalations row.)
11
+ const APP_VERSION = '17';
11
12
  exports.APP_VERSION = APP_VERSION;
12
13
  const APP_ID = 'durable';
13
14
  exports.APP_ID = APP_ID;
@@ -1944,6 +1945,49 @@ const getWorkflowYAML = (app, version) => {
1944
1945
  - ['{collator_trigger.output.data.items}', '{collator_cycle_hook.output.data.cur_index}']
1945
1946
  - ['{@array.get}', duration]
1946
1947
  - ['{@object.get}']
1948
+ # Flatten the CURRENT item's escalation config into this activity's own
1949
+ # output so the escalation block below can address it with flat symbols.
1950
+ # mapOutputData runs before the escalation INSERT in Leg1, so these are
1951
+ # resolved in time. For a plain (non-escalation) collated condition these
1952
+ # resolve to undefined and the INSERT is skipped, just like the inline waiter.
1953
+ output:
1954
+ maps:
1955
+ queueConfig:
1956
+ '@pipe':
1957
+ - ['{collator_trigger.output.data.items}', '{collator_cycle_hook.output.data.cur_index}']
1958
+ - ['{@array.get}', queueConfig]
1959
+ - ['{@object.get}']
1960
+ taskQueue:
1961
+ '@pipe':
1962
+ - ['{collator_trigger.output.data.items}', '{collator_cycle_hook.output.data.cur_index}']
1963
+ - ['{@array.get}', taskQueue]
1964
+ - ['{@object.get}']
1965
+ workflowName:
1966
+ '@pipe':
1967
+ - ['{collator_trigger.output.data.items}', '{collator_cycle_hook.output.data.cur_index}']
1968
+ - ['{@array.get}', workflowName]
1969
+ - ['{@object.get}']
1970
+ # Identical in principle to the inline waiter escalation block, but sourced
1971
+ # from the collated item. The escalation signal_key is auto-derived from this
1972
+ # activity's reentry hook rule (wfs.signal, matching item.signalId), so resolve()
1973
+ # and signal() — which double-fire wfs.signal + wfs.wait — reach it correctly.
1974
+ escalation:
1975
+ type: '{$self.output.data.queueConfig.type}'
1976
+ subtype: '{$self.output.data.queueConfig.subtype}'
1977
+ entity: '{$self.output.data.queueConfig.entity}'
1978
+ description: '{$self.output.data.queueConfig.description}'
1979
+ role: '{$self.output.data.queueConfig.role}'
1980
+ priority: '{$self.output.data.queueConfig.priority}'
1981
+ metadata: '{$self.output.data.queueConfig.metadata}'
1982
+ envelope: '{$self.output.data.queueConfig.envelope}'
1983
+ originId: '{$self.output.data.queueConfig.originId}'
1984
+ parentId: '{$self.output.data.queueConfig.parentId}'
1985
+ initiatedBy: '{$self.output.data.queueConfig.initiatedBy}'
1986
+ traceId: '{$self.output.data.queueConfig.traceId}'
1987
+ spanId: '{$self.output.data.queueConfig.spanId}'
1988
+ expiresAt: '{$self.output.data.queueConfig.expiresAt}'
1989
+ taskQueue: '{$self.output.data.taskQueue}'
1990
+ workflowType: '{$self.output.data.workflowName}'
1947
1991
  hook:
1948
1992
  type: object
1949
1993
  properties:
@@ -97,6 +97,7 @@ import { ConditionQueueConfig } from '../../../types/hmsh_escalations';
97
97
  * {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
98
98
  * atomically at suspension time. Cannot specify both; use the config object's
99
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.
100
+ * @returns The signal payload, `false` if a timeout string was given and it expired,
101
+ * or `null` if the escalation was cancelled via `client.escalations.cancel()`.
101
102
  */
102
- export declare function condition<T>(signalId: string, timeoutOrConfig?: string | ConditionQueueConfig): Promise<T | false>;
103
+ export declare function condition<T>(signalId: string, timeoutOrConfig?: string | ConditionQueueConfig): Promise<T | false | null>;
@@ -102,7 +102,8 @@ const didRun_1 = require("./didRun");
102
102
  * {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
103
103
  * atomically at suspension time. Cannot specify both; use the config object's
104
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.
105
+ * @returns The signal payload, `false` if a timeout string was given and it expired,
106
+ * or `null` if the escalation was cancelled via `client.escalations.cancel()`.
106
107
  */
107
108
  async function condition(signalId, timeoutOrConfig) {
108
109
  const timeout = typeof timeoutOrConfig === 'string' ? timeoutOrConfig : undefined;
@@ -127,7 +128,13 @@ async function condition(signalId, timeoutOrConfig) {
127
128
  if (result?.timedOut) {
128
129
  return false;
129
130
  }
130
- return result.data.data;
131
+ const signalData = result.data?.data;
132
+ // If the escalation was cancelled via cancel(), the signal carries this marker.
133
+ // Return null so the workflow can distinguish cancellation from a real resolution.
134
+ if (signalData && typeof signalData === 'object' && signalData.__escalation_cancelled === true) {
135
+ return null;
136
+ }
137
+ return signalData;
131
138
  }
132
139
  const store = common_1.asyncLocalStorage.getStore();
133
140
  // Emit DISPATCH span in debug mode
@@ -153,7 +160,16 @@ async function condition(signalId, timeoutOrConfig) {
153
160
  type: 'DurableWaitForError',
154
161
  code: common_1.HMSH_CODE_DURABLE_WAIT,
155
162
  ...(timeout ? { duration: (0, common_1.s)(timeout) } : {}),
156
- ...(queueConfig ? { queueConfig } : {}),
163
+ // taskQueue + workflowName ride along so a collated wait (Promise.all) can
164
+ // write a faithful hmsh_escalations row from inside the collator flow —
165
+ // matching the inline waiter, which sources these from its trigger.
166
+ ...(queueConfig
167
+ ? {
168
+ queueConfig,
169
+ taskQueue: store.get('taskQueue'),
170
+ workflowName: store.get('workflowName'),
171
+ }
172
+ : {}),
157
173
  };
158
174
  interruptionRegistry.push(interruptionMessage);
159
175
  await (0, common_1.sleepImmediate)();
@@ -120,10 +120,22 @@ export declare class EscalationClientService {
120
120
  */
121
121
  escalateToRole(params: EscalateToRoleParams): Promise<EscalationEntry | null>;
122
122
  /**
123
- * Cancels a pending escalation without delivering a signal. Terminal rows
124
- * return `already-terminal`.
123
+ * Cancels a pending escalation and delivers a cancellation signal to the
124
+ * waiting workflow so that `condition()` returns `null`. Terminal rows
125
+ * return `already-terminal`. Signal delivery is best-effort post-commit —
126
+ * the committed cancelled row is the durable record; any missed delivery
127
+ * can be detected via a sweep of rows with `status = 'cancelled'` and a
128
+ * non-null `signal_key`.
125
129
  */
126
130
  cancel(id: string, namespace?: string): Promise<CancelEscalationResult>;
131
+ /**
132
+ * Emits local `cancelled` events for a batch of already-cancelled escalation
133
+ * entries. Called by `WorkflowHandleService.terminate()` after the single
134
+ * atomic transaction that interrupts the workflow and cancels its escalations
135
+ * has committed. Fire-and-forget via the configured `events.publish` sink
136
+ * (e.g. NATS) — instance-local, never broadcast via Postgres LISTEN/NOTIFY.
137
+ */
138
+ emitCancelledBatch(entries: EscalationEntry[]): void;
127
139
  /**
128
140
  * Resolves a pending escalation by UUID. Uses an explicit Postgres transaction
129
141
  * with FOR UPDATE + WHERE guard: only one concurrent caller can commit the
@@ -242,16 +242,38 @@ class EscalationClientService {
242
242
  return entry;
243
243
  }
244
244
  /**
245
- * Cancels a pending escalation without delivering a signal. Terminal rows
246
- * return `already-terminal`.
245
+ * Cancels a pending escalation and delivers a cancellation signal to the
246
+ * waiting workflow so that `condition()` returns `null`. Terminal rows
247
+ * return `already-terminal`. Signal delivery is best-effort post-commit —
248
+ * the committed cancelled row is the durable record; any missed delivery
249
+ * can be detected via a sweep of rows with `status = 'cancelled'` and a
250
+ * non-null `signal_key`.
247
251
  */
248
252
  async cancel(id, namespace) {
249
- const hm = await this._engine(null, namespace);
253
+ const ns = namespace ?? factory_1.APP_ID;
254
+ const hm = await this._engine(null, ns);
250
255
  const result = await hm.engine.store.cancelEscalation(id, namespace);
251
- if (result.ok === true)
256
+ if (result.ok === true) {
252
257
  this._emit('cancelled', result.entry);
258
+ if (result.entry.signal_key) {
259
+ await this._deliverEscalationSignal(ns, result.entry.topic, {
260
+ id: result.entry.signal_key,
261
+ data: { __escalation_cancelled: true },
262
+ });
263
+ }
264
+ }
253
265
  return result;
254
266
  }
267
+ /**
268
+ * Emits local `cancelled` events for a batch of already-cancelled escalation
269
+ * entries. Called by `WorkflowHandleService.terminate()` after the single
270
+ * atomic transaction that interrupts the workflow and cancels its escalations
271
+ * has committed. Fire-and-forget via the configured `events.publish` sink
272
+ * (e.g. NATS) — instance-local, never broadcast via Postgres LISTEN/NOTIFY.
273
+ */
274
+ emitCancelledBatch(entries) {
275
+ this._emitMany('cancelled', entries);
276
+ }
255
277
  /**
256
278
  * Resolves a pending escalation by UUID. Uses an explicit Postgres transaction
257
279
  * with FOR UPDATE + WHERE guard: only one concurrent caller can commit the
@@ -157,5 +157,18 @@ declare abstract class StoreService<Provider extends ProviderClient, Transaction
157
157
  };
158
158
  attributes: Record<string, string>;
159
159
  }>;
160
+ /**
161
+ * Fetch just the lineage columns (`parent_id`, `origin_id`) for a job via a
162
+ * single indexed lookup on `key`. Powers the exporter's opt-in
163
+ * `include_lineage` pointer — `parent_id` is the real spawning workflow (never
164
+ * the synthetic collator `$C` job), `origin_id` is the root ancestor.
165
+ *
166
+ * @param jobKey - The job key (e.g., "hmsh:durable:j:workflowId")
167
+ * @returns parent/origin ids (null when absent — a null `parent_id` marks a root)
168
+ */
169
+ getJobLineage?(jobKey: string): Promise<{
170
+ parent_id: string | null;
171
+ origin_id: string | null;
172
+ } | null>;
160
173
  }
161
174
  export { StoreService };
@@ -6,6 +6,11 @@
6
6
  * Fetch job record by key.
7
7
  */
8
8
  export declare const GET_JOB_BY_KEY = "\n SELECT id, key, status, created_at, updated_at, expired_at, is_live\n FROM {schema}.jobs\n WHERE key = $1\n LIMIT 1\n";
9
+ /**
10
+ * Fetch just the lineage columns for a job — a single indexed lookup on `key`.
11
+ * Backs the exporter's opt-in `include_lineage` pointer.
12
+ */
13
+ export declare const GET_JOB_LINEAGE = "\n SELECT parent_id, origin_id\n FROM {schema}.jobs\n WHERE key = $1\n LIMIT 1\n";
9
14
  /**
10
15
  * Fetch all attributes for a job.
11
16
  */
@@ -4,7 +4,7 @@
4
4
  * These queries support the exporter's input enrichment and direct query features.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.buildChildWorkflowInputsQuery = exports.GET_STREAM_HISTORY_BY_JID_AND_AID = exports.GET_STREAM_HISTORY_BY_JID_AND_TYPE = exports.GET_STREAM_HISTORY_BY_JID = exports.GET_PROXYER_STREAM_INPUTS = exports.GET_ACTIVITY_INPUTS = exports.GET_JOB_ATTRIBUTES = exports.GET_JOB_BY_KEY = void 0;
7
+ exports.buildChildWorkflowInputsQuery = exports.GET_STREAM_HISTORY_BY_JID_AND_AID = exports.GET_STREAM_HISTORY_BY_JID_AND_TYPE = exports.GET_STREAM_HISTORY_BY_JID = exports.GET_PROXYER_STREAM_INPUTS = exports.GET_ACTIVITY_INPUTS = exports.GET_JOB_ATTRIBUTES = exports.GET_JOB_LINEAGE = exports.GET_JOB_BY_KEY = void 0;
8
8
  /**
9
9
  * Fetch job record by key.
10
10
  */
@@ -14,6 +14,16 @@ exports.GET_JOB_BY_KEY = `
14
14
  WHERE key = $1
15
15
  LIMIT 1
16
16
  `;
17
+ /**
18
+ * Fetch just the lineage columns for a job — a single indexed lookup on `key`.
19
+ * Backs the exporter's opt-in `include_lineage` pointer.
20
+ */
21
+ exports.GET_JOB_LINEAGE = `
22
+ SELECT parent_id, origin_id
23
+ FROM {schema}.jobs
24
+ WHERE key = $1
25
+ LIMIT 1
26
+ `;
17
27
  /**
18
28
  * Fetch all attributes for a job.
19
29
  */
@@ -216,6 +216,14 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
216
216
  };
217
217
  attributes: Record<string, string>;
218
218
  }>;
219
+ /**
220
+ * Single indexed lookup of the lineage columns for a job key. Returns the real
221
+ * spawning parent (never the synthetic collator `$C` job) and the root ancestor.
222
+ */
223
+ getJobLineage(jobKey: string): Promise<{
224
+ parent_id: string | null;
225
+ origin_id: string | null;
226
+ } | null>;
219
227
  /**
220
228
  * Fetch stream message history for a job from worker_streams.
221
229
  * Returns raw activity input/output data from soft-deleted messages.
@@ -1009,32 +1009,23 @@ class PostgresStoreService extends __1.StoreService {
1009
1009
  */
1010
1010
  async interrupt(topic, jobId, options = {}) {
1011
1011
  try {
1012
- //verify job exists
1012
+ //pre-flight: bail early if already inactive (optimization; hincrbyfloat is the real guard)
1013
1013
  const status = await this.getStatus(jobId, this.appId);
1014
1014
  if (status <= 0) {
1015
- //verify still active; job already completed
1016
1015
  throw new Error(`Job ${jobId} already completed`);
1017
1016
  }
1018
- //decrement job status (:) by 1bil
1019
1017
  const amount = -1000000000;
1020
- const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, {
1021
- appId: this.appId,
1022
- jobId,
1023
- });
1024
- const result = await this.kvsql().hincrbyfloat(jobKey, ':', amount);
1025
- if (result <= amount) {
1026
- //verify active state; job already interrupted
1027
- throw new Error(`Job ${jobId} already completed`);
1028
- }
1029
- //persist the error unless specifically told not to
1018
+ const jobKey = this.mintKey(key_1.KeyType.JOB_STATE, { appId: this.appId, jobId });
1019
+ //build error symbol BEFORE opening the transaction — symbol lookup is read-only
1020
+ let errSymbol;
1021
+ let err;
1030
1022
  if (options.throw !== false) {
1031
- const errKey = `metadata/err`; //job errors are stored at the path `metadata/err`
1032
- const symbolNames = [`$${topic}`]; //the symbol for `metadata/err` is in the backend
1023
+ const errKey = `metadata/err`;
1024
+ const symbolNames = [`$${topic}`];
1033
1025
  const symKeys = await this.getSymbolKeys(symbolNames);
1034
1026
  const symVals = await this.getSymbolValues();
1035
1027
  this.serializer.resetSymbols(symKeys, symVals, {});
1036
- //persists the standard 410 error (job is `gone`)
1037
- const err = JSON.stringify({
1028
+ err = JSON.stringify({
1038
1029
  code: options.code ?? enums_1.HMSH_CODE_INTERRUPT,
1039
1030
  message: options.reason ?? `job [${jobId}] interrupted`,
1040
1031
  stack: options.stack ?? '',
@@ -1042,9 +1033,32 @@ class PostgresStoreService extends __1.StoreService {
1042
1033
  });
1043
1034
  const payload = { [errKey]: amount.toString() };
1044
1035
  const hashData = this.serializer.package(payload, symbolNames);
1045
- const errSymbol = Object.keys(hashData)[0];
1046
- await this.kvsql().hset(jobKey, { [errSymbol]: err });
1036
+ errSymbol = Object.keys(hashData)[0];
1037
+ }
1038
+ //single transaction: status decrement + optional error write + escalation cancel.
1039
+ //WHERE guard on the escalation UPDATE prevents double-cancel;
1040
+ //hincrbyfloat is the atomic idempotency proof checked post-commit.
1041
+ const txn = this.kvsql(this.transact());
1042
+ txn.hincrbyfloat(jobKey, ':', amount);
1043
+ if (errSymbol && err) {
1044
+ txn.hset(jobKey, { [errSymbol]: err });
1047
1045
  }
1046
+ txn.addCommand(`UPDATE public.hmsh_escalations
1047
+ SET status = 'cancelled', updated_at = NOW()
1048
+ WHERE workflow_id = $1
1049
+ AND app_id = $2
1050
+ AND status = 'pending'
1051
+ RETURNING *`, [jobId, this.appId], 'array', (rows) => rows);
1052
+ const results = await txn.exec();
1053
+ //results[0] = new status after hincrbyfloat — the atomic idempotency guard
1054
+ const newStatus = results[0];
1055
+ if (newStatus <= amount) {
1056
+ throw new Error(`Job ${jobId} already completed`);
1057
+ }
1058
+ //fire the callback with any escalation rows cancelled in this same transaction;
1059
+ //no second query, no second transaction
1060
+ const cancelledEntries = (results[results.length - 1] || []);
1061
+ options.onEscalationsCancelled?.(cancelledEntries);
1048
1062
  }
1049
1063
  catch (e) {
1050
1064
  if (!options.suppress) {
@@ -1324,6 +1338,20 @@ class PostgresStoreService extends __1.StoreService {
1324
1338
  }
1325
1339
  return { job, attributes };
1326
1340
  }
1341
+ /**
1342
+ * Single indexed lookup of the lineage columns for a job key. Returns the real
1343
+ * spawning parent (never the synthetic collator `$C` job) and the root ancestor.
1344
+ */
1345
+ async getJobLineage(jobKey) {
1346
+ const { GET_JOB_LINEAGE } = await Promise.resolve().then(() => __importStar(require('./exporter-sql')));
1347
+ const schemaName = this.kvsql().safeName(this.appId);
1348
+ const sql = GET_JOB_LINEAGE.replace(/{schema}/g, schemaName);
1349
+ const result = await this.pgClient.query(sql, [jobKey]);
1350
+ if (result.rows.length === 0)
1351
+ return null;
1352
+ const { parent_id, origin_id } = result.rows[0];
1353
+ return { parent_id: parent_id ?? null, origin_id: origin_id ?? null };
1354
+ }
1327
1355
  /**
1328
1356
  * Fetch stream message history for a job from worker_streams.
1329
1357
  * Returns raw activity input/output data from soft-deleted messages.
@@ -265,6 +265,19 @@ export interface WorkflowExecution {
265
265
  summary: WorkflowExecutionSummary;
266
266
  children?: WorkflowExecution[];
267
267
  stream_history?: StreamHistoryEntry[];
268
+ /**
269
+ * Upward lineage pointer to the real spawning workflow — never the synthetic
270
+ * collator `$C` job. `null` marks a root. Only present when the export was
271
+ * requested with `include_lineage: true`.
272
+ */
273
+ parent_workflow_id?: string | null;
274
+ /**
275
+ * Root ancestor of this execution; `null` for a root itself (identify roots
276
+ * via a null `parent_workflow_id`). Every descendant carries the same
277
+ * `origin_id`, so a subtree is recoverable in one query. Only present when the
278
+ * export was requested with `include_lineage: true`.
279
+ */
280
+ origin_id?: string | null;
268
281
  }
269
282
  /**
270
283
  * Options for `handle.exportExecution()`. Controls event filtering, enrichment,
@@ -310,6 +323,17 @@ export interface ExecutionExportOptions {
310
323
  * @default false
311
324
  */
312
325
  allow_direct_query?: boolean;
326
+ /**
327
+ * When true, populates the export's `parent_workflow_id` and `origin_id` from
328
+ * the jobs table via a single indexed lookup. `parent_workflow_id` is the real
329
+ * spawning workflow (never the synthetic collator `$C` job); `origin_id` is the
330
+ * root ancestor (a null `parent_workflow_id` marks a root). Opt-in so the extra
331
+ * query is only paid when a UI needs upward lineage. Requires a provider that
332
+ * implements `getJobLineage` (e.g., Postgres); a no-op otherwise.
333
+ *
334
+ * @default false
335
+ */
336
+ include_lineage?: boolean;
313
337
  /**
314
338
  * When true, fetches the full stream message history for this workflow
315
339
  * from the worker_streams table and attaches it as `stream_history`.
@@ -130,7 +130,10 @@ type JobInterruptOptions = {
130
130
  */
131
131
  throw?: boolean;
132
132
  /**
133
- * interrupt child/descendant jobs
133
+ * Reserved for future use: interrupt child/descendant jobs.
134
+ * This flag is parsed and threaded through the engine call chain
135
+ * but is **not yet implemented** — child workflows are not interrupted
136
+ * when this is set. Do not rely on it.
134
137
  * @default false
135
138
  */
136
139
  descend?: boolean;
@@ -154,6 +157,13 @@ type JobInterruptOptions = {
154
157
  * Optional stack trace
155
158
  */
156
159
  stack?: string;
160
+ /**
161
+ * Fires synchronously inside `store.interrupt()` after the single
162
+ * transaction that decrements the job semaphore AND cancels pending
163
+ * escalations commits. Only used by `WorkflowHandleService.terminate()`
164
+ * to emit local events without a second transaction or a separate query.
165
+ */
166
+ onEscalationsCancelled?: (entries: any[]) => void;
157
167
  };
158
168
  /**
159
169
  * format when publishing job meta/data on the wire when it completes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.22.7",
3
+ "version": "0.23.0",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",