@hotmeshio/hotmesh 0.22.0 → 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.
@@ -13,6 +13,8 @@ export declare function getSystemHealth(): Promise<SystemHealth>;
13
13
  export declare function deepCopy<T>(obj: T): T;
14
14
  export declare function deterministicRandom(seed: number): number;
15
15
  export declare function guid(size?: number): string;
16
+ /** Returns a standard RFC 4122 v4 UUID (e.g. for use as a DB primary key). */
17
+ export declare function uuid(): string;
16
18
  export declare function sleepFor(ms: number): Promise<void>;
17
19
  export declare function sleepImmediate(): Promise<void>;
18
20
  export declare function XSleepFor(ms: number): {
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.arrayToHash = exports.isStreamMessage = exports.parseStreamMessage = exports.normalizeRetryPolicy = exports.s = exports.isValidCron = exports.restoreHierarchy = exports.getValueByPath = exports.getIndexedHash = exports.getSymVal = exports.getSymKey = exports.formatISODate = exports.getTimeSeries = exports.getSubscriptionTopic = exports.findSubscriptionForTrigger = exports.findTopKey = exports.matchesStatus = exports.matchesStatusCode = exports.polyfill = exports.identifyProvider = exports.XSleepFor = exports.sleepImmediate = exports.sleepFor = exports.guid = exports.deterministicRandom = exports.deepCopy = exports.getSystemHealth = exports.hashOptions = void 0;
6
+ exports.arrayToHash = exports.isStreamMessage = exports.parseStreamMessage = exports.normalizeRetryPolicy = exports.s = exports.isValidCron = exports.restoreHierarchy = exports.getValueByPath = exports.getIndexedHash = exports.getSymVal = exports.getSymKey = exports.formatISODate = exports.getTimeSeries = exports.getSubscriptionTopic = exports.findSubscriptionForTrigger = exports.findTopKey = exports.matchesStatus = exports.matchesStatusCode = exports.polyfill = exports.identifyProvider = exports.XSleepFor = exports.sleepImmediate = exports.sleepFor = exports.uuid = exports.guid = exports.deterministicRandom = exports.deepCopy = exports.getSystemHealth = exports.hashOptions = void 0;
7
7
  const os_1 = __importDefault(require("os"));
8
8
  const crypto_1 = require("crypto");
9
9
  const cron_parser_1 = require("cron-parser");
@@ -47,6 +47,11 @@ function guid(size = enums_1.HMSH_GUID_SIZE) {
47
47
  return `H` + (0, nanoid_1.nanoid)(size);
48
48
  }
49
49
  exports.guid = guid;
50
+ /** Returns a standard RFC 4122 v4 UUID (e.g. for use as a DB primary key). */
51
+ function uuid() {
52
+ return (0, crypto_1.randomUUID)();
53
+ }
54
+ exports.uuid = uuid;
50
55
  async function sleepFor(ms) {
51
56
  return new Promise((resolve) => setTimeout(resolve, ms));
52
57
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -50,7 +50,7 @@
50
50
  "test:durable:signal": "vitest run tests/durable/signal/postgres.test.ts",
51
51
  "test:durable:readonly": "docker compose --profile readonly up -d --build && docker compose exec hotmesh-readonly npx vitest run --config tests/durable/readonly/vitest.config.mts",
52
52
  "test:durable:unknown": "vitest run tests/durable/unknown/postgres.test.ts",
53
- "test:durable:esclations": "HMSH_LOGLEVEL=info vitest run tests/durable/escalations",
53
+ "test:durable:escalations": "HMSH_LOGLEVEL=info vitest run tests/durable/escalations",
54
54
  "test:durable:exporter": "HMSH_LOGLEVEL=info vitest run tests/durable/exporter",
55
55
  "test:durable:exporter:debug": "EXPORT_DEBUG=1 HMSH_LOGLEVEL=error vitest run tests/durable/basic/postgres.test.ts",
56
56
  "test:durable:codec": "vitest run tests/durable/codec/postgres.test.ts",
@@ -85,7 +85,12 @@
85
85
  "test:sub:nats": "vitest run tests/functional/sub/providers/nats/nats.test.ts",
86
86
  "test:trigger": "vitest run tests/unit/services/activities/trigger.test.ts",
87
87
  "test:virtual": "vitest run tests/virtual",
88
- "test:unit": "vitest run tests/unit"
88
+ "test:unit": "vitest run tests/unit",
89
+ "prove": "docker compose exec hotmesh npx vitest run tests/durable 2>&1 | tee /tmp/hmsh-durable.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-durable.txt | tail -5",
90
+ "prove:escalations": "docker compose exec hotmesh npx vitest run tests/durable/escalations/postgres.test.ts 2>&1 | tee /tmp/hmsh-escalations.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-escalations.txt | tail -5",
91
+ "prove:functional": "docker compose exec hotmesh npx vitest run tests/functional 2>&1 | tee /tmp/hmsh-functional.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-functional.txt | tail -5",
92
+ "prove:all": "docker compose exec hotmesh npx vitest run tests/ 2>&1 | tee /tmp/hmsh-all.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-all.txt | tail -5",
93
+ "prove:file": "f() { docker compose exec hotmesh npx vitest run \"$@\" 2>&1 | tee /tmp/hmsh-file.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-file.txt | tail -5; }; f"
89
94
  },
90
95
  "keywords": [
91
96
  "Invisible Infrastructure",
@@ -1,6 +1,6 @@
1
1
  import { HotMesh } from '../hotmesh';
2
2
  import { ClientConfig, ClientWorkflow, Connection, WorkflowOptions } from '../../types/durable';
3
- import { EscalationEntry, ClaimEscalationResult, ClaimByMetadataResult, ReleaseEscalationResult, ResolveEscalationResult, CancelEscalationResult, ListEscalationsParams, CreateEscalationParams, UpdateEscalationParams, AppendMilestonesParams, ClaimEscalationParams, ClaimByMetadataParams, ReleaseEscalationParams, ResolveEscalationParams, ResolveByMetadataParams, EscalateToRoleParams } from '../../types/hmsh_escalations';
3
+ import { EscalationEntry, ClaimEscalationResult, ClaimByMetadataResult, ReleaseEscalationResult, ResolveEscalationResult, CancelEscalationResult, ListEscalationsParams, CreateEscalationParams, UpdateEscalationParams, AppendMilestonesParams, ClaimEscalationParams, ClaimByMetadataParams, ReleaseEscalationParams, ResolveEscalationParams, ResolveByMetadataParams, EscalateToRoleParams, MigrateEscalationParams } from '../../types/hmsh_escalations';
4
4
  /**
5
5
  * Workflow client. Starts workflows, sends signals, and reads results.
6
6
  *
@@ -320,6 +320,26 @@ export declare class ClientService {
320
320
  * ```
321
321
  */
322
322
  resolveByMetadata: (params: ResolveByMetadataParams, namespace?: string) => Promise<ResolveEscalationResult>;
323
+ /**
324
+ * Full-fidelity migration: inserts an escalation row preserving the original
325
+ * UUID and all lifecycle state. Returns the inserted row, or `null` if the
326
+ * UUID already exists (idempotent — safe to call multiple times with the same
327
+ * `params.id`). Use this to migrate rows from a legacy escalation table to
328
+ * `hmsh_escalations` without losing original IDs or state.
329
+ *
330
+ * @example
331
+ * ```typescript
332
+ * const entry = await client.escalations.migrate({
333
+ * id: 'original-uuid',
334
+ * status: 'resolved',
335
+ * resolvedAt: new Date('2025-01-01'),
336
+ * type: 'order-approval',
337
+ * role: 'approver',
338
+ * });
339
+ * // null on subsequent calls with the same id — idempotent
340
+ * ```
341
+ */
342
+ migrate: (params: MigrateEscalationParams, namespace?: string) => Promise<EscalationEntry | null>;
323
343
  /**
324
344
  * Releases all claimed escalations whose `claim_expires_at` has lapsed,
325
345
  * returning them to `pending` so they can be claimed again. Returns the
@@ -334,6 +354,21 @@ export declare class ClientService {
334
354
  */
335
355
  releaseExpired: (namespace?: string) => Promise<number>;
336
356
  };
357
+ /**
358
+ * Delivers a signal to the registered escalation topic.
359
+ *
360
+ * When the topic is known (stored at condition() time), we deliver only to that
361
+ * topic. This avoids writing a stale pending entry to the alternate stream, which
362
+ * would interfere with concurrent consumers in other workflows or tests.
363
+ *
364
+ * When topic is null (legacy/standalone rows with no registered topic), we try
365
+ * both wfs.signal and wfs.wait unconditionally — engine.signal() on the wrong
366
+ * topic stores pending without throwing, so a sequential fallback would skip
367
+ * the second topic and leave single-condition workflows permanently suspended.
368
+ *
369
+ * @private
370
+ */
371
+ private _deliverEscalationSignal;
337
372
  /**
338
373
  * @private
339
374
  */
@@ -528,33 +528,26 @@ class ClientService {
528
528
  const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
529
529
  const hotMeshClient = await this.getHotMeshClient(null, ns);
530
530
  const store = hotMeshClient.engine.store;
531
- // UUID primary-key lookup no namespace filter needed; use existing.namespace for the UPDATE.
532
- const existing = await store.getEscalation(params.id);
533
- if (!existing)
534
- return { ok: false, reason: 'not-found' };
535
- if (existing.status === 'resolved')
536
- return { ok: false, reason: 'already-resolved' };
537
- if (existing.status === 'cancelled')
538
- return { ok: false, reason: 'already-cancelled' };
539
- // Build a single atomic transaction: signal stream INSERTs + escalation UPDATE in one BEGIN/COMMIT.
540
- // engine.signal() with a transaction queues an INSERT into the stream table without executing;
541
- // queueResolveEscalation() queues the status UPDATE; txn.exec() commits all in one round-trip.
542
- const txn = store.transact();
543
- if (existing.signal_key && existing.topic) {
544
- const signalPayload = { id: existing.signal_key, data: params.resolverPayload ?? {} };
545
- try {
546
- const sc = await this.getHotMeshClient(`${ns}.wfs.signal`, ns);
547
- await sc.engine.signal(`${ns}.wfs.signal`, signalPayload, types_1.StreamStatus.SUCCESS, 200, txn);
548
- }
549
- catch { /* no collator hook rule — skip */ }
550
- try {
551
- const wc = await this.getHotMeshClient(`${ns}.wfs.wait`, ns);
552
- await wc.engine.signal(`${ns}.wfs.wait`, signalPayload, types_1.StreamStatus.SUCCESS, 200, txn);
553
- }
554
- catch { /* no waiter hook rule — skip */ }
531
+ // store.resolveEscalation() uses FOR UPDATE inside its CTE, serializing concurrent
532
+ // callers at the DB level. The second caller blocks on the row lock, reads
533
+ // 'already-resolved' after the first commits, and returns — no signal is queued.
534
+ // This eliminates the double-signal race that the previous KVTransaction approach
535
+ // had: two concurrent callers could both pass the unlocked getEscalation() read,
536
+ // both queue signal INSERTs, and both commit them — even though only one UPDATE won.
537
+ const dbResult = await store.resolveEscalation({
538
+ id: params.id,
539
+ resolverPayload: params.resolverPayload,
540
+ // namespace intentionally omitted UUID lookup; passing ns would miss rows
541
+ // stored under namespace 'hmsh' (the engine default) when ns = APP_ID = 'durable'.
542
+ });
543
+ if (!dbResult.ok)
544
+ return dbResult;
545
+ if (dbResult.signalKey) {
546
+ const signalPayload = { id: dbResult.signalKey, data: params.resolverPayload ?? {} };
547
+ const delivered = await this._deliverEscalationSignal(ns, dbResult.topic, signalPayload);
548
+ if (!delivered)
549
+ return { ok: false, reason: 'signal-failed' };
555
550
  }
556
- store.queueResolveEscalation({ id: params.id, namespace: existing.namespace ?? ns, resolverPayload: params.resolverPayload }, txn);
557
- await txn.exec();
558
551
  return { ok: true };
559
552
  },
560
553
  /**
@@ -575,33 +568,49 @@ class ClientService {
575
568
  const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
576
569
  const hotMeshClient = await this.getHotMeshClient(null, ns);
577
570
  const store = hotMeshClient.engine.store;
578
- // Metadata lookup: filter by namespace to scope correctly, but use existing.namespace for the UPDATE.
579
- const existing = await store.findEscalationByMetadata(params.key, params.value, params.roles ?? null);
580
- if (!existing)
581
- return { ok: false, reason: 'not-found' };
582
- if (existing.status === 'resolved')
583
- return { ok: false, reason: 'already-resolved' };
584
- if (existing.status === 'cancelled')
585
- return { ok: false, reason: 'already-cancelled' };
586
- // Same atomic pattern as resolve(): signal INSERTs + UPDATE in one transaction.
587
- const txn = store.transact();
588
- if (existing.signal_key && existing.topic) {
589
- const signalPayload = { id: existing.signal_key, data: params.resolverPayload ?? {} };
590
- try {
591
- const sc = await this.getHotMeshClient(`${ns}.wfs.signal`, ns);
592
- await sc.engine.signal(`${ns}.wfs.signal`, signalPayload, types_1.StreamStatus.SUCCESS, 200, txn);
593
- }
594
- catch { /* no collator hook rule — skip */ }
595
- try {
596
- const wc = await this.getHotMeshClient(`${ns}.wfs.wait`, ns);
597
- await wc.engine.signal(`${ns}.wfs.wait`, signalPayload, types_1.StreamStatus.SUCCESS, 200, txn);
598
- }
599
- catch { /* no waiter hook rule — skip */ }
571
+ // Same FOR UPDATE CTE serialization as resolve(). Metadata filter selects
572
+ // the highest-priority matching row; the lock prevents concurrent callers
573
+ // from both resolving it.
574
+ const dbResult = await store.resolveEscalationByMetadata({
575
+ key: params.key,
576
+ value: params.value,
577
+ resolverPayload: params.resolverPayload,
578
+ roles: params.roles,
579
+ });
580
+ if (!dbResult.ok)
581
+ return dbResult;
582
+ if (dbResult.signalKey) {
583
+ const signalPayload = { id: dbResult.signalKey, data: params.resolverPayload ?? {} };
584
+ const delivered = await this._deliverEscalationSignal(ns, dbResult.topic, signalPayload);
585
+ if (!delivered)
586
+ return { ok: false, reason: 'signal-failed' };
600
587
  }
601
- store.queueResolveEscalation({ id: existing.id, namespace: existing.namespace ?? ns, resolverPayload: params.resolverPayload }, txn);
602
- await txn.exec();
603
588
  return { ok: true };
604
589
  },
590
+ /**
591
+ * Full-fidelity migration: inserts an escalation row preserving the original
592
+ * UUID and all lifecycle state. Returns the inserted row, or `null` if the
593
+ * UUID already exists (idempotent — safe to call multiple times with the same
594
+ * `params.id`). Use this to migrate rows from a legacy escalation table to
595
+ * `hmsh_escalations` without losing original IDs or state.
596
+ *
597
+ * @example
598
+ * ```typescript
599
+ * const entry = await client.escalations.migrate({
600
+ * id: 'original-uuid',
601
+ * status: 'resolved',
602
+ * resolvedAt: new Date('2025-01-01'),
603
+ * type: 'order-approval',
604
+ * role: 'approver',
605
+ * });
606
+ * // null on subsequent calls with the same id — idempotent
607
+ * ```
608
+ */
609
+ migrate: async (params, namespace) => {
610
+ const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
611
+ const hotMeshClient = await this.getHotMeshClient(null, ns);
612
+ return hotMeshClient.engine.store.createEscalationForMigration(params);
613
+ },
605
614
  /**
606
615
  * Releases all claimed escalations whose `claim_expires_at` has lapsed,
607
616
  * returning them to `pending` so they can be claimed again. Returns the
@@ -694,6 +703,46 @@ class ClientService {
694
703
  }
695
704
  }
696
705
  }
706
+ /**
707
+ * Delivers a signal to the registered escalation topic.
708
+ *
709
+ * When the topic is known (stored at condition() time), we deliver only to that
710
+ * topic. This avoids writing a stale pending entry to the alternate stream, which
711
+ * would interfere with concurrent consumers in other workflows or tests.
712
+ *
713
+ * When topic is null (legacy/standalone rows with no registered topic), we try
714
+ * both wfs.signal and wfs.wait unconditionally — engine.signal() on the wrong
715
+ * topic stores pending without throwing, so a sequential fallback would skip
716
+ * the second topic and leave single-condition workflows permanently suspended.
717
+ *
718
+ * @private
719
+ */
720
+ async _deliverEscalationSignal(ns, topic, signalPayload) {
721
+ if (topic) {
722
+ // Registered topic known — deliver precisely, no stream pollution.
723
+ try {
724
+ const tc = await this.getHotMeshClient(topic, ns);
725
+ await tc.engine.signal(topic, signalPayload, types_1.StreamStatus.SUCCESS, 200);
726
+ return true;
727
+ }
728
+ catch { /* topic not currently registered — fall through */ }
729
+ }
730
+ // Topic unknown or primary delivery failed — try both topics unconditionally.
731
+ let delivered = false;
732
+ try {
733
+ const sc = await this.getHotMeshClient(`${ns}.wfs.signal`, ns);
734
+ await sc.engine.signal(`${ns}.wfs.signal`, signalPayload, types_1.StreamStatus.SUCCESS, 200);
735
+ delivered = true;
736
+ }
737
+ catch { /* no collator hook rule for this workflow */ }
738
+ try {
739
+ const wc = await this.getHotMeshClient(`${ns}.wfs.wait`, ns);
740
+ await wc.engine.signal(`${ns}.wfs.wait`, signalPayload, types_1.StreamStatus.SUCCESS, 200);
741
+ delivered = true;
742
+ }
743
+ catch { /* no waiter hook rule for this workflow */ }
744
+ return delivered;
745
+ }
697
746
  /**
698
747
  * @private
699
748
  */
@@ -1,6 +1,6 @@
1
1
  import { HotMesh } from '../hotmesh';
2
2
  import { ContextType, WorkflowInboundCallsInterceptor, WorkflowOutboundCallsInterceptor, ActivityInboundCallsInterceptor } from '../../types/durable';
3
- import { guid } from '../../modules/utils';
3
+ import { guid, uuid } from '../../modules/utils';
4
4
  import { ClientService } from './client';
5
5
  import { ConnectionService } from './connection';
6
6
  import { Search } from './search';
@@ -277,6 +277,12 @@ declare class DurableClass {
277
277
  * Generate a unique identifier for workflow IDs
278
278
  */
279
279
  static guid: typeof guid;
280
+ /**
281
+ * Generate a standard RFC 4122 v4 UUID — use for DB primary keys and
282
+ * any context that requires a hyphenated UUID format rather than the
283
+ * compact HotMesh guid format.
284
+ */
285
+ static uuid: typeof uuid;
280
286
  /**
281
287
  * Provision a scoped Postgres role for a worker. The role can only
282
288
  * dequeue, ack, and respond on its assigned stream names via stored
@@ -310,6 +310,12 @@ DurableClass.interceptorService = new interceptor_1.InterceptorService();
310
310
  * Generate a unique identifier for workflow IDs
311
311
  */
312
312
  DurableClass.guid = utils_1.guid;
313
+ /**
314
+ * Generate a standard RFC 4122 v4 UUID — use for DB primary keys and
315
+ * any context that requires a hyphenated UUID format rather than the
316
+ * compact HotMesh guid format.
317
+ */
318
+ DurableClass.uuid = utils_1.uuid;
313
319
  /**
314
320
  * Provision a scoped Postgres role for a worker. The role can only
315
321
  * dequeue, ack, and respond on its assigned stream names via stored
@@ -237,6 +237,13 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
237
237
  * idempotent re-runs after a crash.
238
238
  */
239
239
  addEscalationToTransaction(params: import('../../../../types/hmsh_escalations').CreateEscalationParams, transaction: import('../../../../types/provider').ProviderTransaction): void;
240
+ /**
241
+ * Full-fidelity INSERT for data migration. Preserves the original `id` (UUID),
242
+ * lifecycle state, and timestamps from the source table. Uses
243
+ * `ON CONFLICT (id) DO NOTHING` so re-running a migration batch is safe —
244
+ * rows that already exist are skipped and `null` is returned for them.
245
+ */
246
+ createEscalationForMigration(params: import('../../../../types/hmsh_escalations').MigrateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
240
247
  getEscalation(id: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
241
248
  getEscalationBySignalKey(signalKey: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
242
249
  listEscalations(params?: import('../../../../types/hmsh_escalations').ListEscalationsParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry[]>;
@@ -1440,6 +1440,68 @@ class PostgresStoreService extends __1.StoreService {
1440
1440
  addEscalationToTransaction(params, transaction) {
1441
1441
  transaction.addCommand(this._escalationInsertSql, this._escalationInsertParams(params), 'void');
1442
1442
  }
1443
+ /**
1444
+ * Full-fidelity INSERT for data migration. Preserves the original `id` (UUID),
1445
+ * lifecycle state, and timestamps from the source table. Uses
1446
+ * `ON CONFLICT (id) DO NOTHING` so re-running a migration batch is safe —
1447
+ * rows that already exist are skipped and `null` is returned for them.
1448
+ */
1449
+ async createEscalationForMigration(params) {
1450
+ const { id, namespace, appId, signalKey, topic, workflowId, taskQueue, workflowType, type, subtype, entity, description, role, priority, originId, parentId, initiatedBy, createdBy, traceId, spanId, escalationPayload, metadata, envelope, expiresAt, status, assignedTo, claimExpiresAt, claimedAt, resolvedAt, resolverPayload, milestones, createdAt, updatedAt, } = params;
1451
+ const result = await this.pgClient.query(`
1452
+ INSERT INTO public.hmsh_escalations
1453
+ (id, namespace, app_id, signal_key, topic, workflow_id, task_queue, workflow_type,
1454
+ type, subtype, entity, description, role, priority,
1455
+ origin_id, parent_id, initiated_by, created_by, trace_id, span_id,
1456
+ escalation_payload, metadata, envelope, expires_at,
1457
+ status, assigned_to, claim_expires_at, claimed_at, resolved_at,
1458
+ resolver_payload, milestones, created_at, updated_at)
1459
+ VALUES
1460
+ ($1, $2, $3, $4, $5, $6, $7, $8,
1461
+ $9, $10, $11, $12, $13, $14,
1462
+ $15, $16, $17, $18, $19, $20,
1463
+ $21, $22, $23, $24,
1464
+ $25, $26, $27, $28, $29,
1465
+ $30, $31, $32, $33)
1466
+ ON CONFLICT (id) DO NOTHING
1467
+ RETURNING *
1468
+ `, [
1469
+ id,
1470
+ namespace ?? 'hmsh',
1471
+ appId ?? 'hmsh',
1472
+ signalKey ?? null,
1473
+ topic ?? null,
1474
+ workflowId ?? null,
1475
+ taskQueue ?? null,
1476
+ workflowType ?? null,
1477
+ type ?? null,
1478
+ subtype ?? null,
1479
+ entity ?? null,
1480
+ description ?? null,
1481
+ role ?? null,
1482
+ priority ?? 5,
1483
+ originId ?? null,
1484
+ parentId ?? null,
1485
+ initiatedBy ?? null,
1486
+ createdBy ?? null,
1487
+ traceId ?? null,
1488
+ spanId ?? null,
1489
+ escalationPayload ? JSON.stringify(escalationPayload) : null,
1490
+ metadata ? JSON.stringify(metadata) : null,
1491
+ envelope ? JSON.stringify(envelope) : null,
1492
+ expiresAt ?? null,
1493
+ status ?? 'pending',
1494
+ assignedTo ?? null,
1495
+ claimExpiresAt ?? null,
1496
+ claimedAt ?? null,
1497
+ resolvedAt ?? null,
1498
+ resolverPayload ? JSON.stringify(resolverPayload) : null,
1499
+ milestones ? JSON.stringify(milestones) : '[]',
1500
+ createdAt ?? new Date(),
1501
+ updatedAt ?? new Date(),
1502
+ ]);
1503
+ return result.rows[0] ?? null;
1504
+ }
1443
1505
  async getEscalation(id, namespace) {
1444
1506
  const result = await this.pgClient.query(`SELECT * FROM public.hmsh_escalations WHERE id = $1${namespace ? ' AND namespace = $2' : ''}`, namespace ? [id, namespace] : [id]);
1445
1507
  return result.rows[0] ?? null;
@@ -210,3 +210,31 @@ export interface EscalateToRoleParams {
210
210
  targetRole: string;
211
211
  namespace?: string;
212
212
  }
213
+ /**
214
+ * Full-fidelity migration params. Extends `CreateEscalationParams` with:
215
+ * - `id` (required) — preserves the original UUID; no auto-generation
216
+ * - lifecycle state fields (`status`, `assignedTo`, `claimExpiresAt`, …) — carry over
217
+ * the exact state of the migrated row so in-flight escalations land correctly
218
+ * - `createdAt` / `updatedAt` — preserve original timestamps
219
+ *
220
+ * The underlying INSERT uses `ON CONFLICT (id) DO NOTHING`, so calling
221
+ * `migrate()` multiple times with the same ID is safe — subsequent calls
222
+ * return `null` without touching the existing row.
223
+ */
224
+ export interface MigrateEscalationParams extends CreateEscalationParams {
225
+ /** Required — preserve the original UUID from the source table. */
226
+ id: string;
227
+ status?: 'pending' | 'claimed' | 'resolved' | 'cancelled' | 'expired';
228
+ assignedTo?: string;
229
+ claimExpiresAt?: Date;
230
+ claimedAt?: Date;
231
+ resolvedAt?: Date;
232
+ resolverPayload?: Record<string, unknown>;
233
+ milestones?: Array<{
234
+ name: string;
235
+ value: unknown;
236
+ [key: string]: unknown;
237
+ }>;
238
+ createdAt?: Date;
239
+ updatedAt?: Date;
240
+ }
@@ -25,4 +25,4 @@ export { ReclaimedMessageType, RetryPolicy, RouterConfig, StreamCode, StreamConf
25
25
  export { context, Context, Counter, Meter, metrics, propagation, SpanContext, Span, SpanStatus, SpanStatusCode, SpanKind, trace, Tracer, ValueType, } from './telemetry';
26
26
  export { WorkListTaskType } from './task';
27
27
  export { TransitionMatch, TransitionRule, Transitions } from './transition';
28
- export { ConditionQueueConfig, EscalationEntry, ClaimEscalationResult, ClaimByMetadataResult, ReleaseEscalationResult, ResolveEscalationResult, CancelEscalationResult, ListEscalationsParams, CreateEscalationParams, UpdateEscalationParams, AppendMilestonesParams, ClaimEscalationParams, ClaimByMetadataParams, ReleaseEscalationParams, ResolveEscalationParams, ResolveByMetadataParams, EscalateToRoleParams, } from './hmsh_escalations';
28
+ export { ConditionQueueConfig, EscalationEntry, ClaimEscalationResult, ClaimByMetadataResult, ReleaseEscalationResult, ResolveEscalationResult, CancelEscalationResult, ListEscalationsParams, CreateEscalationParams, UpdateEscalationParams, AppendMilestonesParams, ClaimEscalationParams, ClaimByMetadataParams, ReleaseEscalationParams, ResolveEscalationParams, ResolveByMetadataParams, EscalateToRoleParams, MigrateEscalationParams, } from './hmsh_escalations';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -50,7 +50,7 @@
50
50
  "test:durable:signal": "vitest run tests/durable/signal/postgres.test.ts",
51
51
  "test:durable:readonly": "docker compose --profile readonly up -d --build && docker compose exec hotmesh-readonly npx vitest run --config tests/durable/readonly/vitest.config.mts",
52
52
  "test:durable:unknown": "vitest run tests/durable/unknown/postgres.test.ts",
53
- "test:durable:esclations": "HMSH_LOGLEVEL=info vitest run tests/durable/escalations",
53
+ "test:durable:escalations": "HMSH_LOGLEVEL=info vitest run tests/durable/escalations",
54
54
  "test:durable:exporter": "HMSH_LOGLEVEL=info vitest run tests/durable/exporter",
55
55
  "test:durable:exporter:debug": "EXPORT_DEBUG=1 HMSH_LOGLEVEL=error vitest run tests/durable/basic/postgres.test.ts",
56
56
  "test:durable:codec": "vitest run tests/durable/codec/postgres.test.ts",
@@ -85,7 +85,13 @@
85
85
  "test:sub:nats": "vitest run tests/functional/sub/providers/nats/nats.test.ts",
86
86
  "test:trigger": "vitest run tests/unit/services/activities/trigger.test.ts",
87
87
  "test:virtual": "vitest run tests/virtual",
88
- "test:unit": "vitest run tests/unit"
88
+ "test:unit": "vitest run tests/unit",
89
+
90
+ "prove": "docker compose exec hotmesh npx vitest run tests/durable 2>&1 | tee /tmp/hmsh-durable.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-durable.txt | tail -5",
91
+ "prove:escalations": "docker compose exec hotmesh npx vitest run tests/durable/escalations/postgres.test.ts 2>&1 | tee /tmp/hmsh-escalations.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-escalations.txt | tail -5",
92
+ "prove:functional": "docker compose exec hotmesh npx vitest run tests/functional 2>&1 | tee /tmp/hmsh-functional.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-functional.txt | tail -5",
93
+ "prove:all": "docker compose exec hotmesh npx vitest run tests/ 2>&1 | tee /tmp/hmsh-all.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-all.txt | tail -5",
94
+ "prove:file": "f() { docker compose exec hotmesh npx vitest run \"$@\" 2>&1 | tee /tmp/hmsh-file.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-file.txt | tail -5; }; f"
89
95
  },
90
96
  "keywords": [
91
97
  "Invisible Infrastructure",