@hotmeshio/hotmesh 0.15.1 → 0.16.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.
@@ -2,3 +2,4 @@
2
2
  import { AsyncLocalStorage } from 'async_hooks';
3
3
  export declare const asyncLocalStorage: AsyncLocalStorage<Map<string, any>>;
4
4
  export declare const activityAsyncLocalStorage: AsyncLocalStorage<Map<string, any>>;
5
+ export declare const virtualAsyncLocalStorage: AsyncLocalStorage<Map<string, any>>;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.activityAsyncLocalStorage = exports.asyncLocalStorage = void 0;
3
+ exports.virtualAsyncLocalStorage = exports.activityAsyncLocalStorage = exports.asyncLocalStorage = void 0;
4
4
  const async_hooks_1 = require("async_hooks");
5
5
  exports.asyncLocalStorage = new async_hooks_1.AsyncLocalStorage();
6
6
  exports.activityAsyncLocalStorage = new async_hooks_1.AsyncLocalStorage();
7
+ exports.virtualAsyncLocalStorage = new async_hooks_1.AsyncLocalStorage();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -30,7 +30,7 @@ interface StateContext {
30
30
  export declare function setState(instance: StateContext, transaction?: ProviderTransaction): Promise<string>;
31
31
  export declare function getState(instance: StateContext): Promise<void>;
32
32
  export declare function setStatus(instance: StateContext, amount: number, transaction?: ProviderTransaction): Promise<void | any>;
33
- export declare function bindJobMetadata(instance: StateContext): void;
33
+ export declare function bindJobMetadata(_instance: StateContext): void;
34
34
  export declare function bindActivityMetadata(instance: StateContext): void;
35
35
  export declare function bindJobState(instance: StateContext, state: StringAnyType): Promise<void>;
36
36
  export declare function bindActivityState(instance: StateContext, state: StringAnyType): void;
@@ -68,8 +68,9 @@ async function setStatus(instance, amount, transaction) {
68
68
  }
69
69
  exports.setStatus = setStatus;
70
70
  //─── metadata binding ────────────────────────────────────────────────
71
- function bindJobMetadata(instance) {
72
- instance.context.metadata.ju = (0, utils_1.formatISODate)(new Date());
71
+ function bindJobMetadata(_instance) {
72
+ // ju (job_updated) is maintained by the jobs.updated_at trigger —
73
+ // no need to serialize it into jobs_attributes on every activity.
73
74
  }
74
75
  exports.bindJobMetadata = bindJobMetadata;
75
76
  function bindActivityMetadata(instance) {
@@ -676,9 +676,23 @@ class ExporterService {
676
676
  const metadata = state?.output?.metadata ?? state?.metadata;
677
677
  const stateData = state?.output?.data ?? state?.data;
678
678
  const jobCreated = metadata?.jc ?? stateData?.jc ?? metadata?.ac;
679
- const jobUpdated = metadata?.ju ?? stateData?.ju ?? metadata?.au;
680
679
  const startTime = parseTimestamp(jobCreated);
681
- const closeTime = parseTimestamp(jobUpdated);
680
+ // Derive close time from the latest activity update (au) in the
681
+ // timeline, falling back to metadata.au. ju is no longer updated
682
+ // after creation — jobs.updated_at is the authoritative source,
683
+ // but it's not available in the DurableJobExport format.
684
+ let latestAu = null;
685
+ for (const entry of raw.timeline || []) {
686
+ const val = entry.value;
687
+ const au = val?.au;
688
+ if (au) {
689
+ const parsed = parseTimestamp(au);
690
+ if (parsed && (!latestAu || parsed > latestAu)) {
691
+ latestAu = parsed;
692
+ }
693
+ }
694
+ }
695
+ const closeTime = latestAu ?? parseTimestamp(metadata?.au);
682
696
  // ── Synthetic workflow_execution_started ─────────────────────────
683
697
  if (startTime) {
684
698
  events.push(makeEvent(nextId++, 'workflow_execution_started', 'workflow', startTime, null, false, {
@@ -30,6 +30,9 @@ class CronHandler {
30
30
  * ```
31
31
  */
32
32
  nextDelay(cronExpression) {
33
+ if (cronExpression == null || typeof cronExpression !== 'string') {
34
+ return -1;
35
+ }
33
36
  if (!(0, utils_1.isValidCron)(cronExpression)) {
34
37
  throw new Error(`Invalid cron expression: ${cronExpression}`);
35
38
  }
@@ -35,7 +35,7 @@ exports.MDATA_SYMBOLS = {
35
35
  ],
36
36
  },
37
37
  JOB_UPDATE: {
38
- KEYS: ['ju', 'err'],
38
+ KEYS: ['err'],
39
39
  },
40
40
  };
41
41
  class SerializerService {
@@ -153,6 +153,20 @@ const KVTables = (context) => ({
153
153
  FOR EACH ROW EXECUTE FUNCTION ${schemaName}.update_jobs_updated_at();
154
154
  `);
155
155
  }
156
+ // v0.15.2: add dedicated is_live partial index for hot-path queries
157
+ const { rows: idxRows } = await client.query(`SELECT 1 FROM pg_indexes WHERE indexname = 'idx_jobs_key_live' AND schemaname = $1 LIMIT 1`, [schemaName]);
158
+ if (idxRows.length === 0) {
159
+ await client.query(`
160
+ CREATE INDEX IF NOT EXISTS idx_jobs_key_live
161
+ ON ${jobsTable} (key) WHERE is_live;
162
+ `);
163
+ }
164
+ // v0.15.2: partial index for sorted_set scheduler hot path
165
+ await client.query(`
166
+ CREATE INDEX IF NOT EXISTS idx_task_schedules_active
167
+ ON ${schemaName}.task_schedules (key, score)
168
+ WHERE expiry IS NULL;
169
+ `);
156
170
  },
157
171
  async createTables(client, appName) {
158
172
  try {
@@ -251,27 +265,40 @@ const KVTables = (context) => ({
251
265
  CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_context_gin
252
266
  ON ${fullTableName} USING GIN (context);
253
267
  `);
254
- // Create partitions using a DO block
268
+ // Create partitions with fillfactor 70: reserves 30% page space
269
+ // for HOT updates (status update cycle in setStatusAndCollateGuid)
255
270
  await client.query(`
256
271
  DO $$
257
272
  BEGIN
258
273
  FOR i IN 0..7 LOOP
259
274
  EXECUTE format(
260
- 'CREATE TABLE IF NOT EXISTS ${fullTableName}_part_%s PARTITION OF ${fullTableName}
261
- FOR VALUES WITH (modulus 8, remainder %s)',
275
+ 'CREATE TABLE IF NOT EXISTS ${fullTableName}_part_%s PARTITION OF ${fullTableName}
276
+ FOR VALUES WITH (modulus 8, remainder %s)
277
+ WITH (fillfactor = 70)',
262
278
  i, i
263
279
  );
264
280
  END LOOP;
265
281
  END$$;
266
282
  `);
267
- // Create optimized indexes
283
+ // Original index for queries that filter on expired_at directly
284
+ // (e.g. _hmget's WHERE expired_at IS NULL OR expired_at > NOW())
268
285
  await client.query(`
269
- CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_expired_at
286
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_expired_at
270
287
  ON ${fullTableName} (key, expired_at) INCLUDE (is_live);
271
288
  `);
289
+ // Dedicated partial index for the hot-path (WHERE key = $1 AND is_live).
290
+ // Covers the uniqueness trigger, hget, hgetall, hincrbyfloat, and
291
+ // the two-pass upsert — all of which use AND is_live directly.
272
292
  await client.query(`
273
- CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_entity_status
274
- ON ${fullTableName} (entity, status);
293
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_live
294
+ ON ${fullTableName} (key)
295
+ WHERE is_live;
296
+ `);
297
+ // status in INCLUDE (not key) so semaphore increments don't
298
+ // force index key updates — allows HOT on the hottest write path.
299
+ await client.query(`
300
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_entity_status
301
+ ON ${fullTableName} (entity) INCLUDE (status);
275
302
  `);
276
303
  await client.query(`
277
304
  CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_expired_at
@@ -303,7 +330,9 @@ const KVTables = (context) => ({
303
330
  BEFORE INSERT OR UPDATE ON ${fullTableName}
304
331
  FOR EACH ROW EXECUTE PROCEDURE ${schemaName}.update_is_live();
305
332
  `);
306
- // Create function to enforce uniqueness of live jobs
333
+ // Enforce uniqueness of live jobs via trigger.
334
+ // Uses is_live partial index for the EXISTS check (existing rows
335
+ // have is_live maintained by trg_update_is_live).
307
336
  await client.query(`
308
337
  CREATE OR REPLACE FUNCTION ${schemaName}.enforce_live_job_uniqueness()
309
338
  RETURNS TRIGGER AS $$
@@ -312,8 +341,8 @@ const KVTables = (context) => ({
312
341
  PERFORM pg_advisory_xact_lock(hashtextextended(NEW.key, 0));
313
342
  IF EXISTS (
314
343
  SELECT 1 FROM ${fullTableName}
315
- WHERE key = NEW.key
316
- AND (expired_at IS NULL OR expired_at > NOW())
344
+ WHERE key = NEW.key
345
+ AND is_live
317
346
  AND id <> NEW.id
318
347
  ) THEN
319
348
  RAISE EXCEPTION 'A live job with key % already exists.', NEW.key;
@@ -384,14 +413,16 @@ const KVTables = (context) => ({
384
413
  FOR EACH ROW
385
414
  EXECUTE FUNCTION ${schemaName}.update_attributes_updated_at();
386
415
  `);
387
- // Create partitions for attributes table
416
+ // Create partitions with fillfactor 70: reserves 30% page space
417
+ // for HOT updates (frequent upserts in hincrbyfloat/collateLeg2Entry)
388
418
  await client.query(`
389
419
  DO $$
390
420
  BEGIN
391
421
  FOR i IN 0..7 LOOP
392
422
  EXECUTE format(
393
- 'CREATE TABLE IF NOT EXISTS ${attributesTableName}_part_%s PARTITION OF ${attributesTableName}
394
- FOR VALUES WITH (modulus 8, remainder %s)',
423
+ 'CREATE TABLE IF NOT EXISTS ${attributesTableName}_part_%s PARTITION OF ${attributesTableName}
424
+ FOR VALUES WITH (modulus 8, remainder %s)
425
+ WITH (fillfactor = 70)',
395
426
  i, i
396
427
  );
397
428
  END LOOP;
@@ -433,8 +464,16 @@ const KVTables = (context) => ({
433
464
  );
434
465
  `);
435
466
  await client.query(`
436
- CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_score_member
467
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_key_score_member
437
468
  ON ${fullTableName} (key, score, member);
469
+ `);
470
+ // Partial index for the scheduler hot path (zrangebyscore).
471
+ // Most entries have no expiry; this avoids the OR filter that
472
+ // prevents clean index-only scans.
473
+ await client.query(`
474
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_active
475
+ ON ${fullTableName} (key, score)
476
+ WHERE expiry IS NULL;
438
477
  `);
439
478
  break;
440
479
  default:
@@ -292,11 +292,19 @@ function _hset(context, key, fields, options) {
292
292
  params.push(key, fields[':'], options?.entity ?? null);
293
293
  }
294
294
  else {
295
- // Update existing job or insert new one
295
+ // Two-pass upsert: UPDATE existing live job, INSERT only if no
296
+ // live job exists. Avoids ON CONFLICT seq_scan on partitioned
297
+ // tables that lack a unique partial index.
296
298
  sql = `
299
+ WITH existing AS (
300
+ UPDATE ${targetTable}
301
+ SET status = $2
302
+ WHERE key = $1 AND is_live
303
+ RETURNING 1
304
+ )
297
305
  INSERT INTO ${targetTable} (id, key, status, entity)
298
- VALUES (gen_random_uuid(), $1, $2, $3)
299
- ON CONFLICT (key) WHERE is_live DO UPDATE SET status = EXCLUDED.status
306
+ SELECT gen_random_uuid(), $1, $2, $3
307
+ WHERE NOT EXISTS (SELECT 1 FROM existing)
300
308
  RETURNING 1 as count
301
309
  `;
302
310
  params.push(key, fields[':'], options?.entity ?? null);
@@ -503,31 +511,20 @@ function _hgetall(context, key) {
503
511
  const tableName = context.tableForKey(key, 'hash');
504
512
  const isJobsTableResult = (0, utils_1.isJobsTable)(tableName);
505
513
  if (isJobsTableResult) {
514
+ // Single CTE: reads jobs row once, then joins attributes
506
515
  const sql = `
507
516
  WITH valid_job AS (
508
517
  SELECT id, status, context
509
518
  FROM ${tableName}
510
519
  WHERE key = $1 AND is_live
511
- ),
512
- job_data AS (
513
- SELECT 'status' AS field, status::text AS value
514
- FROM ${tableName}
515
- WHERE key = $1 AND is_live
516
-
517
- UNION ALL
518
-
519
- SELECT 'context' AS field, context::text AS value
520
- FROM ${tableName}
521
- WHERE key = $1 AND is_live
522
- ),
523
- attribute_data AS (
524
- SELECT symbol || dimension AS field, value
525
- FROM ${tableName}_attributes
526
- WHERE job_id IN (SELECT id FROM valid_job)
527
520
  )
528
- SELECT * FROM job_data
521
+ SELECT 'status' AS field, status::text AS value FROM valid_job
522
+ UNION ALL
523
+ SELECT 'context' AS field, context::text AS value FROM valid_job
529
524
  UNION ALL
530
- SELECT * FROM attribute_data;
525
+ SELECT symbol || dimension AS field, value
526
+ FROM ${tableName}_attributes
527
+ WHERE job_id = (SELECT id FROM valid_job);
531
528
  `;
532
529
  return { sql, params: [key] };
533
530
  }
@@ -83,6 +83,17 @@ const zsetModule = (context) => ({
83
83
  _zrange(key, start, stop, facet) {
84
84
  const tableName = context.tableForKey(key, 'sorted_set');
85
85
  const selectColumns = facet === 'WITHSCORES' ? 'member, score' : 'member';
86
+ // Fast path: (0, -1) means "get all" — skip COUNT/ROW_NUMBER entirely
87
+ if (start === 0 && stop === -1) {
88
+ const sql = `
89
+ SELECT ${selectColumns}
90
+ FROM ${tableName}
91
+ WHERE key = $1 AND (expiry IS NULL OR expiry > NOW())
92
+ ORDER BY score ASC, member ASC;
93
+ `;
94
+ return { sql, params: [context.storageKey(key)] };
95
+ }
96
+ // General case: negative indices require COUNT for resolution
86
97
  const sql = `
87
98
  WITH total_entries AS (
88
99
  SELECT COUNT(*) - 1 AS max_index FROM ${tableName}
@@ -475,12 +475,10 @@ class PostgresStoreService extends __1.StoreService {
475
475
  const symVals = await this.getSymbolValues();
476
476
  this.serializer.resetSymbols(symKeys, symVals, dIds);
477
477
  const hashData = this.serializer.package(state, symbolNames);
478
- if (status !== null) {
479
- hashData[':'] = status.toString();
480
- }
481
- else {
482
- delete hashData[':'];
483
- }
478
+ // ':' (status) is NOT written to jobs_attributes — jobs.status is
479
+ // maintained by setStatus/setStatusAndCollateGuid. The attribute row
480
+ // was redundant, never read, and contended on every activity.
481
+ delete hashData[':'];
484
482
  await this.kvsql(transaction).hset(hashKey, hashData);
485
483
  return jobId;
486
484
  }
@@ -42,7 +42,7 @@ class TaskService {
42
42
  async registerTimeHook(jobId, gId, activityId, type, inSeconds = enums_1.HMSH_FIDELITY_SECONDS, dad, transaction) {
43
43
  const fromNow = Date.now() + inSeconds * 1000;
44
44
  const fidelityMS = enums_1.HMSH_FIDELITY_SECONDS * 1000;
45
- const awakenTimeSlot = Math.floor(fromNow / fidelityMS) * fidelityMS;
45
+ const awakenTimeSlot = Math.ceil(fromNow / fidelityMS) * fidelityMS;
46
46
  await this.store.registerTimeHook(jobId, gId, activityId, type, awakenTimeSlot, dad, transaction);
47
47
  }
48
48
  /**
@@ -1,5 +1,5 @@
1
1
  import { HotMesh } from '../hotmesh';
2
- import { VirtualConnectParams, VirtualCronParams, VirtualExecParams, VirtualFlushParams, VirtualInstanceOptions, VirtualInterruptParams } from '../../types/virtual';
2
+ import { VirtualConnectParams, VirtualContext, VirtualCronParams, VirtualExecParams, VirtualFlushParams, VirtualInstanceOptions, VirtualInterruptParams } from '../../types/virtual';
3
3
  import { ProviderConfig, ProvidersConfig } from '../../types/provider';
4
4
  /**
5
5
  * Virtual creates a virtual network of functions, connecting any
@@ -185,6 +185,27 @@ declare class Virtual {
185
185
  * ```
186
186
  */
187
187
  static interrupt(params: VirtualInterruptParams): Promise<boolean>;
188
+ /**
189
+ * Returns the execution context for the current Virtual callback.
190
+ * Must be called from inside a `Virtual.cron` or `Virtual.exec`
191
+ * callback — throws if called outside that scope.
192
+ *
193
+ * @example
194
+ * ```typescript
195
+ * await Virtual.cron({
196
+ * topic: 'my.cron',
197
+ * connection,
198
+ * args: [],
199
+ * options: { id: 'daily', interval: '0 0 * * *' },
200
+ * callback: async () => {
201
+ * const ctx = Virtual.getContext();
202
+ * console.log(ctx.workflowId); // 'daily'
203
+ * console.log(ctx.attempt); // 1
204
+ * },
205
+ * });
206
+ * ```
207
+ */
208
+ static getContext(): VirtualContext;
188
209
  /**
189
210
  * Shuts down all virtual instances. Call this method
190
211
  * from the SIGTERM handler in your application.
@@ -6,8 +6,14 @@ const hotmesh_1 = require("../hotmesh");
6
6
  const enums_1 = require("../../modules/enums");
7
7
  const utils_1 = require("../../modules/utils");
8
8
  const key_1 = require("../../modules/key");
9
+ const storage_1 = require("../../modules/storage");
9
10
  const cron_1 = require("../pipe/functions/cron");
10
11
  const factory_1 = require("./schemas/factory");
12
+ const DEFAULT_RETRY_POLICY = {
13
+ maximumAttempts: 3,
14
+ backoffCoefficient: 2,
15
+ maximumInterval: 30,
16
+ };
11
17
  /**
12
18
  * Virtual creates a virtual network of functions, connecting any
13
19
  * function as an idempotent, cacheable endpoint. Call functions
@@ -173,13 +179,19 @@ class Virtual {
173
179
  {
174
180
  topic: params.topic,
175
181
  connection,
176
- retry: params.retry ?? {
177
- maximumAttempts: 3,
178
- backoffCoefficient: 2,
179
- maximumInterval: 30,
180
- },
182
+ retry: params.retry ?? DEFAULT_RETRY_POLICY,
181
183
  callback: async function (input) {
182
- const response = await params.callback.apply(this, input.data.args);
184
+ const context = new Map([
185
+ ['topic', params.topic],
186
+ ['workflowId', input.metadata.jid ?? ''],
187
+ ['workflowName', input.metadata.wfn ?? ''],
188
+ ['dimension', input.metadata.dad ?? ''],
189
+ ['attempt', input.metadata.try ?? 1],
190
+ ['guid', input.metadata.guid],
191
+ ['traceId', input.metadata.trc ?? ''],
192
+ ['spanId', input.metadata.spn ?? ''],
193
+ ]);
194
+ const response = await storage_1.virtualAsyncLocalStorage.run(context, () => params.callback.apply(this, input.data.args));
183
195
  return {
184
196
  metadata: { ...input.metadata },
185
197
  data: { response },
@@ -386,6 +398,42 @@ class Virtual {
386
398
  }
387
399
  return true;
388
400
  }
401
+ /**
402
+ * Returns the execution context for the current Virtual callback.
403
+ * Must be called from inside a `Virtual.cron` or `Virtual.exec`
404
+ * callback — throws if called outside that scope.
405
+ *
406
+ * @example
407
+ * ```typescript
408
+ * await Virtual.cron({
409
+ * topic: 'my.cron',
410
+ * connection,
411
+ * args: [],
412
+ * options: { id: 'daily', interval: '0 0 * * *' },
413
+ * callback: async () => {
414
+ * const ctx = Virtual.getContext();
415
+ * console.log(ctx.workflowId); // 'daily'
416
+ * console.log(ctx.attempt); // 1
417
+ * },
418
+ * });
419
+ * ```
420
+ */
421
+ static getContext() {
422
+ const store = storage_1.virtualAsyncLocalStorage.getStore();
423
+ if (!store) {
424
+ throw new Error('Virtual.getContext() called outside of a Virtual callback execution context');
425
+ }
426
+ return {
427
+ topic: store.get('topic'),
428
+ workflowId: store.get('workflowId'),
429
+ workflowName: store.get('workflowName'),
430
+ dimension: store.get('dimension'),
431
+ attempt: store.get('attempt'),
432
+ guid: store.get('guid'),
433
+ traceId: store.get('traceId'),
434
+ spanId: store.get('spanId'),
435
+ };
436
+ }
389
437
  /**
390
438
  * Shuts down all virtual instances. Call this method
391
439
  * from the SIGTERM handler in your application.
@@ -15,7 +15,7 @@ export { ExtensionType, JobCompletionOptions, JobData, JobsData, JobInterruptOpt
15
15
  export { MappingStatements } from './map';
16
16
  export { Pipe, PipeContext, PipeItem, PipeItems, PipeObject, ReduceObject, } from './pipe';
17
17
  export { ProviderClass, ProviderClient, ProviderConfig, ProviderTransaction, Providers, TransactionResultList, ProviderNativeClient, ProviderOptions, } from './provider';
18
- export { VirtualConnectParams, VirtualExecParams, VirtualCronParams, VirtualExecOptions, VirtualCronOptions, VirtualInterruptOptions, VirtualInterruptParams, VirtualFlushParams, } from './virtual';
18
+ export { VirtualConnectParams, VirtualContext, VirtualExecParams, VirtualCronParams, VirtualExecOptions, VirtualCronOptions, VirtualInterruptOptions, VirtualInterruptParams, VirtualFlushParams, } from './virtual';
19
19
  export { PostgresClassType, PostgresClientOptions, PostgresClientType, PostgresConsumerGroup, PostgresPendingMessage, PostgresPoolClientType, PostgresQueryConfigType, PostgresQueryResultType, PostgresStreamMessage, PostgresStreamOptions, PostgresTransaction, } from './postgres';
20
20
  export { ActivateMessage, CronMessage, JobMessage, JobMessageCallback, PingMessage, PongMessage, QuorumMessage, QuorumMessageCallback, QuorumProfile, RollCallMessage, RollCallOptions, SubscriptionCallback, SubscriptionOptions, SystemHealth, ThrottleMessage, ThrottleOptions, WorkMessage, } from './quorum';
21
21
  export { NatsAckPolicy, NatsAckPolicyExplicitType, NatsClassType, NatsClientType, NatsClientOptions, NatsConsumerConfigType, NatsJetStreamManager, NatsConnection, NatsJetStreamType, NatsConnectionOptions, NatsConsumerConfig, NatsConsumerInfo, NatsConsumerManager, NatsDeliveryInfo, NatsJetStreamOptions, NatsError, NatsErrorType, NatsJetStreamClient, NatsJsMsg, NatsMessageType, NatsMsgExpect, NatsPubAck, NatsPubAckType, NatsPublishOptions, NatsRetentionPolicy, NatsRetentionPolicyWorkqueueType, NatsSequenceInfo, NatsStorageMemoryType, NatsStorageType, NatsStreamConfig, NatsStreamInfo, NatsStreamManager, NatsStreamConfigType, NatsStreamInfoType, NatsStreamOptions, NatsStreamState, NatsTransaction, } from './nats';
@@ -208,4 +208,72 @@ interface VirtualInterruptParams {
208
208
  */
209
209
  options: VirtualInterruptOptions;
210
210
  }
211
- export { VirtualConnectParams, VirtualExecParams, VirtualCronParams, VirtualExecOptions, VirtualCronOptions, VirtualInterruptOptions, VirtualInterruptParams, VirtualFlushOptions, VirtualFlushParams, VirtualInstanceOptions, };
211
+ /**
212
+ * Execution context available inside Virtual callbacks via
213
+ * `Virtual.getContext()`. Populated automatically by the
214
+ * AsyncLocalStorage wrapper in `Virtual.connect()`.
215
+ *
216
+ * @example
217
+ * ```typescript
218
+ * await Virtual.cron({
219
+ * topic: 'billing.daily',
220
+ * connection,
221
+ * args: [],
222
+ * options: { id: 'billing-run', interval: '0 0 * * *' },
223
+ * callback: async () => {
224
+ * const ctx = Virtual.getContext();
225
+ * // ctx.workflowId === 'billing-run'
226
+ * // ctx.topic === 'billing.daily'
227
+ * // ctx.guid === unique per invocation
228
+ * },
229
+ * });
230
+ * ```
231
+ */
232
+ interface VirtualContext {
233
+ /**
234
+ * The worker topic that routed this invocation.
235
+ * Matches the `topic` passed to `Virtual.cron()` or
236
+ * `Virtual.connect()`.
237
+ */
238
+ topic: string;
239
+ /**
240
+ * Workflow / job ID. For cron callbacks this is the `id`
241
+ * from `options.id`. For `Virtual.exec` calls this is the
242
+ * auto-generated or user-supplied job identifier.
243
+ */
244
+ workflowId: string;
245
+ /**
246
+ * Internal workflow name (the graph subscription topic,
247
+ * e.g. `hmsh.cron` or `hmsh.call`).
248
+ */
249
+ workflowName: string;
250
+ /**
251
+ * Dimensional address for this execution within the
252
+ * workflow's DAG. Encodes the cycle iteration and
253
+ * parallel branch position.
254
+ */
255
+ dimension: string;
256
+ /**
257
+ * Current retry attempt (1-based). `1` on the first try,
258
+ * incremented on each retry per the `RetryPolicy`.
259
+ */
260
+ attempt: number;
261
+ /**
262
+ * Globally unique identifier for the stream message that
263
+ * triggered this callback. A new GUID is minted for every
264
+ * invocation, including retries and cycle iterations, so it
265
+ * serves as an idempotency key for exactly-once processing.
266
+ */
267
+ guid: string;
268
+ /**
269
+ * OpenTelemetry trace ID propagated from the originating
270
+ * workflow. Empty string when tracing is not configured.
271
+ */
272
+ traceId: string;
273
+ /**
274
+ * OpenTelemetry span ID propagated from the parent activity.
275
+ * Empty string when tracing is not configured.
276
+ */
277
+ spanId: string;
278
+ }
279
+ export { VirtualConnectParams, VirtualContext, VirtualExecParams, VirtualCronParams, VirtualExecOptions, VirtualCronOptions, VirtualInterruptOptions, VirtualInterruptParams, VirtualFlushOptions, VirtualFlushParams, VirtualInstanceOptions, };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",