@hotmeshio/hotmesh 0.22.2 → 0.22.4

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.
@@ -170,6 +170,13 @@ class HotMesh {
170
170
  * functions that consume messages from Postgres streams — they can
171
171
  * run on the same process or on entirely separate servers.
172
172
  *
173
+ * Pass `config.events` to receive lifecycle events from this engine:
174
+ * - `system.engine.{appId}.started` — fires when init completes.
175
+ * - `system.engine.{appId}.deployed` — fires after schema deploy.
176
+ * - `system.engine.{appId}.stopped` — fires when `stop()` is called.
177
+ * - `system.escalation.{id}.created` — fires from the hook Leg1 path
178
+ * (YAML `escalation:` block) when the escalation row commits.
179
+ *
173
180
  * @param config - Engine connection, worker definitions, app ID, and options.
174
181
  * @returns A running HotMesh instance joined to the quorum.
175
182
  */
@@ -196,6 +203,24 @@ class HotMesh {
196
203
  });
197
204
  }
198
205
  await Init.doWork(instance, config, instance.logger);
206
+ // Thread events.publish to the store (used by the hook Leg1 path and deploy).
207
+ if (config.events?.publish && instance.engine?.store) {
208
+ instance.engine.store.eventsPublish = config.events.publish;
209
+ }
210
+ // Retain for stop() lifecycle event.
211
+ if (config.events?.publish) {
212
+ instance._eventsPublish = config.events.publish;
213
+ const ts = new Date().toISOString();
214
+ const event = {
215
+ event_id: `${config.appId}:started:${ts}`,
216
+ type: `system.engine.${config.appId}.started`,
217
+ ts,
218
+ namespace: instance.namespace,
219
+ app_id: config.appId,
220
+ data: { appId: config.appId, guid: instance.guid },
221
+ };
222
+ void Promise.resolve(config.events.publish(event)).catch(() => { });
223
+ }
199
224
  return instance;
200
225
  }
201
226
  /**
@@ -489,6 +514,18 @@ class HotMesh {
489
514
  this.workers?.forEach((worker) => {
490
515
  worker.stop();
491
516
  });
517
+ if (this._eventsPublish) {
518
+ const ts = new Date().toISOString();
519
+ const event = {
520
+ event_id: `${this.appId}:stopped:${ts}`,
521
+ type: `system.engine.${this.appId}.stopped`,
522
+ ts,
523
+ namespace: this.namespace,
524
+ app_id: this.appId,
525
+ data: { appId: this.appId, guid: this.guid },
526
+ };
527
+ void Promise.resolve(this._eventsPublish(event)).catch(() => { });
528
+ }
492
529
  }
493
530
  /**
494
531
  * @private
@@ -71,6 +71,18 @@ const KVTables = (context) => ({
71
71
  await client.release();
72
72
  }
73
73
  }
74
+ // Fire system.engine.{appId}.deployed post-deploy (best-effort).
75
+ if (context.eventsPublish) {
76
+ const ts = new Date().toISOString();
77
+ void Promise.resolve(context.eventsPublish({
78
+ event_id: `${appName}:deployed:${ts}`,
79
+ type: `system.engine.${appName}.deployed`,
80
+ ts,
81
+ namespace: appName,
82
+ app_id: appName,
83
+ data: { appId: appName },
84
+ })).catch(() => { });
85
+ }
74
86
  },
75
87
  getAdvisoryLockId(appName) {
76
88
  return this.hashStringToInt(appName);
@@ -166,6 +178,145 @@ const KVTables = (context) => ({
166
178
  ON ${jobsTable} (key) WHERE is_live;
167
179
  `);
168
180
  }
181
+ // v0.22.0: origin_id, parent_id on per-app jobs table (workflow lineage).
182
+ // ADD COLUMN IF NOT EXISTS on a nullable column with no DEFAULT is catalog-only
183
+ // in PG11+ — no table rewrite, brief AccessExclusiveLock only.
184
+ await client.query(`
185
+ ALTER TABLE ${jobsTable} ADD COLUMN IF NOT EXISTS origin_id TEXT;
186
+ `);
187
+ await client.query(`
188
+ ALTER TABLE ${jobsTable} ADD COLUMN IF NOT EXISTS parent_id TEXT;
189
+ `);
190
+ // Partial indexes — only cover the opt-in rows so build is near-instant.
191
+ const { rows: originIdxRows } = await client.query(`SELECT 1 FROM pg_indexes WHERE indexname = $1 AND schemaname = $2 LIMIT 1`, [`idx_${schemaName}_jobs_origin`, schemaName]);
192
+ if (originIdxRows.length === 0) {
193
+ await client.query(`
194
+ CREATE INDEX IF NOT EXISTS idx_${schemaName}_jobs_origin
195
+ ON ${jobsTable}(origin_id) WHERE origin_id IS NOT NULL;
196
+ `);
197
+ }
198
+ const { rows: parentIdxRows } = await client.query(`SELECT 1 FROM pg_indexes WHERE indexname = $1 AND schemaname = $2 LIMIT 1`, [`idx_${schemaName}_jobs_parent`, schemaName]);
199
+ if (parentIdxRows.length === 0) {
200
+ await client.query(`
201
+ CREATE INDEX IF NOT EXISTS idx_${schemaName}_jobs_parent
202
+ ON ${jobsTable}(parent_id) WHERE parent_id IS NOT NULL;
203
+ `);
204
+ }
205
+ // ─── v0.22.0+: public.hmsh_escalations global table migrations ───────────
206
+ // Use the fixed advisory XACT lock so concurrent deployments of DIFFERENT
207
+ // appIds (each holding their own per-appId session lock) don't race on the
208
+ // shared public table DDL. Transaction-level lock auto-releases on commit/rollback.
209
+ await client.query('BEGIN');
210
+ await client.query('SELECT pg_advisory_xact_lock($1)', [0x484D5348]);
211
+ // v0.22.0: create hmsh_escalations if missing (upgrade from pre-0.22.x).
212
+ // Column list matches createTables() exactly; task_id is NOT here because
213
+ // the ADD COLUMN below is the canonical way to add it to both fresh and
214
+ // existing tables in a single idempotent statement.
215
+ await client.query(`
216
+ CREATE TABLE IF NOT EXISTS public.hmsh_escalations (
217
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
218
+ namespace TEXT NOT NULL,
219
+ app_id TEXT NOT NULL,
220
+ signal_key TEXT,
221
+ topic TEXT,
222
+ workflow_id TEXT,
223
+ task_queue TEXT,
224
+ workflow_type TEXT,
225
+ type TEXT,
226
+ subtype TEXT,
227
+ entity TEXT,
228
+ description TEXT,
229
+ role TEXT,
230
+ status TEXT NOT NULL DEFAULT 'pending',
231
+ priority INT NOT NULL DEFAULT 5,
232
+ assigned_to TEXT,
233
+ assigned_until TIMESTAMPTZ,
234
+ claimed_at TIMESTAMPTZ,
235
+ claim_expires_at TIMESTAMPTZ,
236
+ resolved_at TIMESTAMPTZ,
237
+ escalation_payload JSONB,
238
+ resolver_payload JSONB,
239
+ envelope JSONB,
240
+ metadata JSONB,
241
+ origin_id TEXT,
242
+ parent_id TEXT,
243
+ initiated_by TEXT,
244
+ created_by TEXT,
245
+ milestones JSONB NOT NULL DEFAULT '[]',
246
+ trace_id TEXT,
247
+ span_id TEXT,
248
+ expires_at TIMESTAMPTZ,
249
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
250
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
251
+ );
252
+ `);
253
+ // v0.22.3: task_id column — catalog-only ADD COLUMN (nullable TEXT, no DEFAULT,
254
+ // no table rewrite). IF NOT EXISTS makes this idempotent across all upgrade paths.
255
+ await client.query(`
256
+ ALTER TABLE public.hmsh_escalations ADD COLUMN IF NOT EXISTS task_id TEXT;
257
+ `);
258
+ // Ensure signal_key unique index exists.
259
+ await client.query(`
260
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_hmsh_esc_signal_key
261
+ ON public.hmsh_escalations(namespace, app_id, signal_key)
262
+ WHERE signal_key IS NOT NULL;
263
+ `);
264
+ // v0.22.0: idx_hmsh_esc_available — predicate must be status='pending'.
265
+ // Fresh 0.22.0 installs may have this with the correct predicate already;
266
+ // ensure it exists.
267
+ await client.query(`
268
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_available
269
+ ON public.hmsh_escalations(namespace, app_id, role, priority ASC, created_at ASC)
270
+ WHERE status = 'pending';
271
+ `);
272
+ await client.query(`
273
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_available_expiry
274
+ ON public.hmsh_escalations(namespace, app_id, role, assigned_until, created_at DESC);
275
+ `);
276
+ // v0.22.2: idx_hmsh_esc_assigned predicate changed from status='claimed' to
277
+ // status='pending' to match the implicit claim model. If the old predicate
278
+ // is present, drop and recreate so claimEscalation / list queries use it.
279
+ const { rows: assignedDefRows } = await client.query(`
280
+ SELECT indexdef FROM pg_indexes WHERE indexname = 'idx_hmsh_esc_assigned' LIMIT 1
281
+ `);
282
+ if (assignedDefRows.length > 0 &&
283
+ assignedDefRows[0].indexdef.includes("status = 'claimed'")) {
284
+ await client.query(`DROP INDEX IF EXISTS idx_hmsh_esc_assigned;`);
285
+ }
286
+ await client.query(`
287
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_assigned
288
+ ON public.hmsh_escalations(assigned_to, assigned_until, created_at DESC)
289
+ WHERE status = 'pending' AND assigned_to IS NOT NULL;
290
+ `);
291
+ // v0.22.2: claim_expiry sweeper index is obsolete (releaseExpiredEscalations
292
+ // is a no-op in the implicit-claim model — availability is query-time computed).
293
+ await client.query(`DROP INDEX IF EXISTS idx_hmsh_esc_claim_expiry;`);
294
+ await client.query(`
295
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_entity
296
+ ON public.hmsh_escalations(namespace, app_id, entity, created_at DESC)
297
+ WHERE entity IS NOT NULL;
298
+ `);
299
+ await client.query(`
300
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_workflow
301
+ ON public.hmsh_escalations(workflow_id)
302
+ WHERE workflow_id IS NOT NULL;
303
+ `);
304
+ await client.query(`
305
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_origin
306
+ ON public.hmsh_escalations(origin_id)
307
+ WHERE origin_id IS NOT NULL;
308
+ `);
309
+ await client.query(`
310
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_metadata
311
+ ON public.hmsh_escalations USING GIN(metadata jsonb_path_ops);
312
+ `);
313
+ // v0.22.3: task_id partial index.
314
+ await client.query(`
315
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_task
316
+ ON public.hmsh_escalations(task_id)
317
+ WHERE task_id IS NOT NULL;
318
+ `);
319
+ await client.query('COMMIT');
169
320
  },
170
321
  async createTables(client, appName) {
171
322
  try {
@@ -253,16 +404,15 @@ const KVTables = (context) => ({
253
404
  CREATE INDEX IF NOT EXISTS idx_hmsh_esc_available_expiry
254
405
  ON public.hmsh_escalations(namespace, app_id, role, assigned_until, created_at DESC);
255
406
  `);
407
+ // idx_hmsh_esc_assigned: implicit-claim model uses status='pending', not 'claimed'
408
+ await client.query(`DROP INDEX IF EXISTS idx_hmsh_esc_assigned;`);
256
409
  await client.query(`
257
410
  CREATE INDEX IF NOT EXISTS idx_hmsh_esc_assigned
258
411
  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';
412
+ WHERE status = 'pending' AND assigned_to IS NOT NULL;
265
413
  `);
414
+ // idx_hmsh_esc_claim_expiry: no longer used (releaseExpiredEscalations is a no-op)
415
+ await client.query(`DROP INDEX IF EXISTS idx_hmsh_esc_claim_expiry;`);
266
416
  await client.query(`
267
417
  CREATE INDEX IF NOT EXISTS idx_hmsh_esc_entity
268
418
  ON public.hmsh_escalations(namespace, app_id, entity, created_at DESC)
@@ -281,6 +431,15 @@ const KVTables = (context) => ({
281
431
  await client.query(`
282
432
  CREATE INDEX IF NOT EXISTS idx_hmsh_esc_metadata
283
433
  ON public.hmsh_escalations USING GIN(metadata jsonb_path_ops);
434
+ `);
435
+ // task_id: nullable passthrough column for downstream compat (no FK)
436
+ await client.query(`
437
+ ALTER TABLE public.hmsh_escalations ADD COLUMN IF NOT EXISTS task_id TEXT;
438
+ `);
439
+ await client.query(`
440
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_task
441
+ ON public.hmsh_escalations(task_id)
442
+ WHERE task_id IS NOT NULL;
284
443
  `);
285
444
  break;
286
445
  case 'relational_connection':
@@ -19,6 +19,8 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
19
19
  pgClient: PostgresClientType;
20
20
  kvTables: ReturnType<typeof KVTables>;
21
21
  isScout: boolean;
22
+ /** Set by HotMesh.init() when `events.publish` is configured. Used by hook.ts Leg1 path. */
23
+ eventsPublish?: (event: import('../../../../types/system_events').SystemEvent) => void | Promise<void>;
22
24
  transact(): ProviderTransaction;
23
25
  constructor(storeClient: ProviderClient);
24
26
  init(namespace: string, appId: string, logger: ILogger, guid?: string, role?: string): Promise<HotMeshApps>;
@@ -246,6 +248,7 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
246
248
  createEscalationForMigration(params: import('../../../../types/hmsh_escalations').MigrateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
247
249
  getEscalation(id: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
248
250
  getEscalationBySignalKey(signalKey: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
251
+ private _escalationFilterConditions;
249
252
  listEscalations(params?: import('../../../../types/hmsh_escalations').ListEscalationsParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry[]>;
250
253
  countEscalations(params?: import('../../../../types/hmsh_escalations').ListEscalationsParams): Promise<number>;
251
254
  claimEscalation(params: import('../../../../types/hmsh_escalations').ClaimEscalationParams): Promise<import('../../../../types/hmsh_escalations').ClaimEscalationResult>;
@@ -259,26 +262,19 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
259
262
  signalKey?: string | null;
260
263
  topic?: string | null;
261
264
  }>;
262
- /**
263
- * Queues the escalation UPDATE into an existing transaction without executing it.
264
- * Used by client.ts resolve() to commit the status change atomically with signal delivery.
265
- */
266
- queueResolveEscalation(params: {
267
- id: string;
268
- namespace?: string;
269
- resolverPayload?: Record<string, unknown>;
270
- }, transaction: {
271
- addCommand: (sql: string, params: any[], returnType: string) => any;
272
- }): void;
273
- /**
274
- * Finds the highest-priority pending or claimed escalation matching the given metadata filter.
275
- * Used by client.ts resolveByMetadata() to get routing info before building the atomic transaction.
276
- */
277
- findEscalationByMetadata(key: string, value: string, roles: string[] | null, namespace?: string): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
278
265
  cancelEscalation(id: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult>;
279
266
  escalateEscalationToRole(params: import('../../../../types/hmsh_escalations').EscalateToRoleParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
280
267
  updateEscalation(params: import('../../../../types/hmsh_escalations').UpdateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
281
268
  appendEscalationMilestones(params: import('../../../../types/hmsh_escalations').AppendMilestonesParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
269
+ claimManyEscalations(params: import('../../../../types/hmsh_escalations').ClaimManyParams): Promise<{
270
+ entries: import('../../../../types/hmsh_escalations').EscalationEntry[];
271
+ skipped: number;
272
+ }>;
273
+ escalateManyEscalationsToRole(params: import('../../../../types/hmsh_escalations').EscalateManyToRoleParams): Promise<number>;
274
+ updateManyEscalationsPriority(params: import('../../../../types/hmsh_escalations').UpdateManyPriorityParams): Promise<number>;
275
+ resolveManyEscalations(params: import('../../../../types/hmsh_escalations').ResolveManyParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry[]>;
276
+ escalationStats(params?: import('../../../../types/hmsh_escalations').StatsEscalationsParams): Promise<import('../../../../types/hmsh_escalations').EscalationStats>;
277
+ listDistinctEscalationTypes(namespace?: string): Promise<string[]>;
282
278
  releaseExpiredEscalations(_namespace?: string): Promise<number>;
283
279
  }
284
280
  export { PostgresStoreService };