@hotmeshio/hotmesh 0.23.0 → 0.25.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.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -885,7 +885,12 @@ class WorkerService {
885
885
  code: enums_1.HMSH_CODE_DURABLE_ALL,
886
886
  items: [...interruptionRegistry],
887
887
  size: interruptionRegistry.length,
888
- workflowDimension: workflowDimension || '',
888
+ // Use the items' execution dimension (all items in one Promise.all share it),
889
+ // not the raw input dimension. In a continued generation (post-continueAsNew)
890
+ // the effective dimension is the generation prefix `$N`, which the items carry
891
+ // but `workflowInput.workflowDimension` does not — mirrors the single-condition
892
+ // path above so the collator's harvested markers land where condition() replays.
893
+ workflowDimension: interruptionRegistry[0].workflowDimension || workflowDimension || '',
889
894
  index: execIndex,
890
895
  originJobId: originJobId || workflowId,
891
896
  parentWorkflowId: workflowId,
@@ -143,11 +143,21 @@ export declare class EscalationClientService {
143
143
  * durable proof. Signal delivery is best-effort post-commit — the resolved
144
144
  * row is the recovery record for any missed delivery. Returns the updated
145
145
  * row as `entry` on success.
146
+ *
147
+ * Pass `params.metadata` to merge the resolution outcome ("what actually
148
+ * happened") into the row's GIN-indexed `metadata` in the same atomic UPDATE,
149
+ * making it `@>`-queryable alongside the creation metadata ("what was
150
+ * intended"). This is distinct from `resolverPayload`, which is delivered to
151
+ * the waiting workflow as `condition()`'s return value and is not GIN-indexed.
146
152
  */
147
153
  resolve(params: ResolveEscalationParams, namespace?: string): Promise<ResolveEscalationResult>;
148
154
  /**
149
155
  * Resolves the highest-priority matching escalation by metadata filter,
150
156
  * then delivers its signal. Same transaction + WHERE guard semantics as `resolve()`.
157
+ *
158
+ * The `key`/`value` are the *selector* used to find the row; pass
159
+ * `params.metadata` to additionally merge a resolution patch into that row's
160
+ * GIN-indexed `metadata` in the same atomic UPDATE. See {@link resolve}.
151
161
  */
152
162
  resolveByMetadata(params: ResolveByMetadataParams, namespace?: string): Promise<ResolveEscalationResult>;
153
163
  /**
@@ -182,6 +192,10 @@ export declare class EscalationClientService {
182
192
  * Bulk-resolves pending escalations by id-set. No signal delivery — intended
183
193
  * for redirect-to-triage flows where no workflow is waiting. Returns the
184
194
  * resolved rows.
195
+ *
196
+ * Pass `params.metadata` to merge a resolution patch into every winning
197
+ * (still-pending) row's GIN-indexed `metadata` in the single atomic UPDATE.
198
+ * See {@link resolve}.
185
199
  */
186
200
  resolveMany(params: ResolveManyParams): Promise<EscalationEntry[]>;
187
201
  /**
@@ -281,12 +281,18 @@ class EscalationClientService {
281
281
  * durable proof. Signal delivery is best-effort post-commit — the resolved
282
282
  * row is the recovery record for any missed delivery. Returns the updated
283
283
  * row as `entry` on success.
284
+ *
285
+ * Pass `params.metadata` to merge the resolution outcome ("what actually
286
+ * happened") into the row's GIN-indexed `metadata` in the same atomic UPDATE,
287
+ * making it `@>`-queryable alongside the creation metadata ("what was
288
+ * intended"). This is distinct from `resolverPayload`, which is delivered to
289
+ * the waiting workflow as `condition()`'s return value and is not GIN-indexed.
284
290
  */
285
291
  async resolve(params, namespace) {
286
292
  const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
287
293
  const hm = await this._engine(null, ns);
288
294
  const store = hm.engine.store;
289
- const dbResult = await store.resolveEscalation({ id: params.id, resolverPayload: params.resolverPayload });
295
+ const dbResult = await store.resolveEscalation({ id: params.id, resolverPayload: params.resolverPayload, metadata: params.metadata });
290
296
  if (!dbResult.ok)
291
297
  return dbResult;
292
298
  if (dbResult.signalKey) {
@@ -301,12 +307,16 @@ class EscalationClientService {
301
307
  /**
302
308
  * Resolves the highest-priority matching escalation by metadata filter,
303
309
  * then delivers its signal. Same transaction + WHERE guard semantics as `resolve()`.
310
+ *
311
+ * The `key`/`value` are the *selector* used to find the row; pass
312
+ * `params.metadata` to additionally merge a resolution patch into that row's
313
+ * GIN-indexed `metadata` in the same atomic UPDATE. See {@link resolve}.
304
314
  */
305
315
  async resolveByMetadata(params, namespace) {
306
316
  const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
307
317
  const hm = await this._engine(null, ns);
308
318
  const store = hm.engine.store;
309
- const dbResult = await store.resolveEscalationByMetadata({ key: params.key, value: params.value, resolverPayload: params.resolverPayload, roles: params.roles });
319
+ const dbResult = await store.resolveEscalationByMetadata({ key: params.key, value: params.value, resolverPayload: params.resolverPayload, roles: params.roles, metadata: params.metadata });
310
320
  if (!dbResult.ok)
311
321
  return dbResult;
312
322
  if (dbResult.signalKey) {
@@ -366,6 +376,10 @@ class EscalationClientService {
366
376
  * Bulk-resolves pending escalations by id-set. No signal delivery — intended
367
377
  * for redirect-to-triage flows where no workflow is waiting. Returns the
368
378
  * resolved rows.
379
+ *
380
+ * Pass `params.metadata` to merge a resolution patch into every winning
381
+ * (still-pending) row's GIN-indexed `metadata` in the single atomic UPDATE.
382
+ * See {@link resolve}.
369
383
  */
370
384
  async resolveMany(params) {
371
385
  const hm = await this._engine(null, params.namespace);
@@ -315,6 +315,26 @@ const KVTables = (context) => ({
315
315
  CREATE INDEX IF NOT EXISTS idx_hmsh_esc_task
316
316
  ON public.hmsh_escalations(task_id)
317
317
  WHERE task_id IS NOT NULL;
318
+ `);
319
+ // v0.25.0: escalationStats indexes. Callers filter by role (optional) and
320
+ // namespace (optional) — never namespace-first — so the pending index leads
321
+ // with role and the two windowed indexes lead with their time column: the
322
+ // scan stays window-bounded even for unfiltered (admin) calls. Trailing
323
+ // columns cover every column the stats aggregates touch, keeping each of
324
+ // the three CTE scans index-only.
325
+ await client.query(`
326
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_pending
327
+ ON public.hmsh_escalations(role, type, assigned_to, assigned_until, namespace)
328
+ WHERE status = 'pending';
329
+ `);
330
+ await client.query(`
331
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_created
332
+ ON public.hmsh_escalations(created_at, role, namespace);
333
+ `);
334
+ await client.query(`
335
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_resolved
336
+ ON public.hmsh_escalations(resolved_at, type, role, namespace)
337
+ WHERE status = 'resolved';
318
338
  `);
319
339
  await client.query('COMMIT');
320
340
  },
@@ -440,6 +460,23 @@ const KVTables = (context) => ({
440
460
  CREATE INDEX IF NOT EXISTS idx_hmsh_esc_task
441
461
  ON public.hmsh_escalations(task_id)
442
462
  WHERE task_id IS NOT NULL;
463
+ `);
464
+ // escalationStats indexes: role/time-led so aggregate scans stay
465
+ // bounded (backlog or window) and index-only. See migrate() for
466
+ // the column-order rationale.
467
+ await client.query(`
468
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_pending
469
+ ON public.hmsh_escalations(role, type, assigned_to, assigned_until, namespace)
470
+ WHERE status = 'pending';
471
+ `);
472
+ await client.query(`
473
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_created
474
+ ON public.hmsh_escalations(created_at, role, namespace);
475
+ `);
476
+ await client.query(`
477
+ CREATE INDEX IF NOT EXISTS idx_hmsh_esc_stats_resolved
478
+ ON public.hmsh_escalations(resolved_at, type, role, namespace)
479
+ WHERE status = 'resolved';
443
480
  `);
444
481
  break;
445
482
  case 'relational_connection':
@@ -1769,8 +1769,11 @@ class PostgresStoreService extends __1.StoreService {
1769
1769
  return { ok: true, entry: row.entry_json };
1770
1770
  }
1771
1771
  async resolveEscalation(params) {
1772
- const { id, namespace, resolverPayload } = params;
1772
+ const { id, namespace, resolverPayload, metadata } = params;
1773
1773
  const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1774
+ // metaJson is bound at a fixed index; the CASE no-ops when null so resolution
1775
+ // can merge into the GIN-indexed metadata without disturbing the param layout.
1776
+ const metaJson = metadata ? JSON.stringify(metadata) : null;
1774
1777
  // Explicit transaction: FOR UPDATE locks the row; WHERE guard on UPDATE is the TOCTOU
1775
1778
  // barrier — a concurrent caller whose UPDATE matches 0 rows sees 'already-resolved'.
1776
1779
  // On crash before COMMIT neither write lands; on crash after COMMIT both are durable.
@@ -1789,9 +1792,13 @@ class PostgresStoreService extends __1.StoreService {
1789
1792
  return { ok: false, reason: 'already-cancelled' };
1790
1793
  }
1791
1794
  const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1792
- SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2, updated_at = NOW()
1793
- WHERE id = $1 ${namespace ? 'AND namespace = $3' : ''} AND status = 'pending'
1794
- RETURNING *`, namespace ? [id, payloadJson, namespace] : [id, payloadJson]);
1795
+ SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1796
+ metadata = CASE WHEN $3::jsonb IS NOT NULL
1797
+ THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
1798
+ ELSE metadata END,
1799
+ updated_at = NOW()
1800
+ WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''} AND status = 'pending'
1801
+ RETURNING *`, namespace ? [id, payloadJson, metaJson, namespace] : [id, payloadJson, metaJson]);
1795
1802
  if (!updateResult.rows[0]) {
1796
1803
  await this.pgClient.query('ROLLBACK');
1797
1804
  return { ok: false, reason: 'already-resolved' };
@@ -1805,9 +1812,10 @@ class PostgresStoreService extends __1.StoreService {
1805
1812
  }
1806
1813
  }
1807
1814
  async resolveEscalationByMetadata(params) {
1808
- const { key, value, namespace, resolverPayload, roles } = params;
1815
+ const { key, value, namespace, resolverPayload, roles, metadata } = params;
1809
1816
  const filter = JSON.stringify({ [key]: value });
1810
1817
  const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1818
+ const metaJson = metadata ? JSON.stringify(metadata) : null;
1811
1819
  await this.pgClient.query('BEGIN');
1812
1820
  try {
1813
1821
  const lockResult = await this.pgClient.query(`SELECT id, signal_key, topic, status FROM public.hmsh_escalations
@@ -1827,9 +1835,13 @@ class PostgresStoreService extends __1.StoreService {
1827
1835
  return { ok: false, reason: 'already-cancelled' };
1828
1836
  }
1829
1837
  const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1830
- SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2, updated_at = NOW()
1838
+ SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1839
+ metadata = CASE WHEN $3::jsonb IS NOT NULL
1840
+ THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
1841
+ ELSE metadata END,
1842
+ updated_at = NOW()
1831
1843
  WHERE id = $1 AND status = 'pending'
1832
- RETURNING *`, [id, payloadJson]);
1844
+ RETURNING *`, [id, payloadJson, metaJson]);
1833
1845
  if (!updateResult.rows[0]) {
1834
1846
  await this.pgClient.query('ROLLBACK');
1835
1847
  return { ok: false, reason: 'already-resolved' };
@@ -2007,17 +2019,21 @@ class PostgresStoreService extends __1.StoreService {
2007
2019
  return result.rowCount ?? 0;
2008
2020
  }
2009
2021
  async resolveManyEscalations(params) {
2010
- const { ids, namespace, resolverPayload } = params;
2022
+ const { ids, namespace, resolverPayload, metadata } = params;
2011
2023
  const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
2024
+ const metaJson = metadata ? JSON.stringify(metadata) : null;
2012
2025
  const result = await this.pgClient.query(`UPDATE public.hmsh_escalations
2013
2026
  SET status = 'resolved',
2014
2027
  resolved_at = NOW(),
2015
2028
  resolver_payload = $1,
2029
+ metadata = CASE WHEN $3::jsonb IS NOT NULL
2030
+ THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
2031
+ ELSE metadata END,
2016
2032
  updated_at = NOW()
2017
2033
  WHERE id = ANY($2::uuid[])
2018
- ${namespace ? 'AND namespace = $3' : ''}
2034
+ ${namespace ? 'AND namespace = $4' : ''}
2019
2035
  AND status = 'pending'
2020
- RETURNING *`, namespace ? [payloadJson, ids, namespace] : [payloadJson, ids]);
2036
+ RETURNING *`, namespace ? [payloadJson, ids, metaJson, namespace] : [payloadJson, ids, metaJson]);
2021
2037
  return result.rows;
2022
2038
  }
2023
2039
  async escalationStats(params = {}) {
@@ -2035,45 +2051,65 @@ class PostgresStoreService extends __1.StoreService {
2035
2051
  queryParams.push(namespace);
2036
2052
  if (roles?.length)
2037
2053
  queryParams.push(roles);
2054
+ // Three bounded sources instead of one all-history scan (v0.25.0):
2055
+ // backlog — status='pending' partial (bounded by queue depth, never history)
2056
+ // created — created_at window range (all statuses)
2057
+ // resolved — status='resolved' + resolved_at window range
2058
+ // Totals derive from the grouped CTEs (NULL role/type groups included, so
2059
+ // totals match the pre-0.25.0 all-row counts); NULL groups are excluded
2060
+ // only from the by_role/by_type rollups, as before. resolved_at is only
2061
+ // ever set alongside status='resolved' (and never survives a transition
2062
+ // back to pending), so the status predicate is a pure index enabler.
2063
+ // Roles/types with no pending rows and no window activity no longer emit
2064
+ // zero-count rollup entries.
2038
2065
  const result = await this.pgClient.query(`
2039
- WITH base AS (
2040
- SELECT * FROM public.hmsh_escalations
2041
- WHERE true ${nsClause} ${rolClause}
2042
- ),
2043
- totals AS (
2044
- SELECT
2045
- COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
2046
- COUNT(*) FILTER (WHERE status = 'pending'
2047
- AND assigned_to IS NOT NULL
2048
- AND assigned_until IS NOT NULL AND assigned_until > NOW())::int AS claimed,
2049
- COUNT(*) FILTER (WHERE created_at >= NOW() - ($1 * INTERVAL '1 hour'))::int AS created,
2050
- COUNT(*) FILTER (WHERE resolved_at >= NOW() - ($1 * INTERVAL '1 hour'))::int AS resolved
2051
- FROM base
2052
- ),
2053
- by_role AS (
2066
+ WITH pending_by_role AS (
2054
2067
  SELECT role,
2055
- COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
2056
- COUNT(*) FILTER (WHERE status = 'pending'
2057
- AND assigned_to IS NOT NULL
2068
+ COUNT(*)::int AS pending,
2069
+ COUNT(*) FILTER (WHERE assigned_to IS NOT NULL
2058
2070
  AND assigned_until IS NOT NULL AND assigned_until > NOW())::int AS claimed
2059
- FROM base WHERE role IS NOT NULL
2071
+ FROM public.hmsh_escalations
2072
+ WHERE status = 'pending' ${nsClause} ${rolClause}
2060
2073
  GROUP BY role
2061
2074
  ),
2062
- by_type AS (
2075
+ pending_by_type AS (
2063
2076
  SELECT type,
2064
- COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
2065
- COUNT(*) FILTER (WHERE status = 'pending'
2066
- AND assigned_to IS NOT NULL
2067
- AND assigned_until IS NOT NULL AND assigned_until > NOW())::int AS claimed,
2068
- COUNT(*) FILTER (WHERE resolved_at >= NOW() - ($1 * INTERVAL '1 hour'))::int AS resolved
2069
- FROM base WHERE type IS NOT NULL
2077
+ COUNT(*)::int AS pending,
2078
+ COUNT(*) FILTER (WHERE assigned_to IS NOT NULL
2079
+ AND assigned_until IS NOT NULL AND assigned_until > NOW())::int AS claimed
2080
+ FROM public.hmsh_escalations
2081
+ WHERE status = 'pending' ${nsClause} ${rolClause}
2082
+ GROUP BY type
2083
+ ),
2084
+ created_agg AS (
2085
+ SELECT COUNT(*)::int AS created
2086
+ FROM public.hmsh_escalations
2087
+ WHERE created_at >= NOW() - ($1 * INTERVAL '1 hour') ${nsClause} ${rolClause}
2088
+ ),
2089
+ resolved_by_type AS (
2090
+ SELECT type, COUNT(*)::int AS resolved
2091
+ FROM public.hmsh_escalations
2092
+ WHERE status = 'resolved'
2093
+ AND resolved_at >= NOW() - ($1 * INTERVAL '1 hour') ${nsClause} ${rolClause}
2070
2094
  GROUP BY type
2071
2095
  )
2072
2096
  SELECT
2073
- t.pending, t.claimed, t.created, t.resolved,
2074
- COALESCE((SELECT json_agg(json_build_object('role',role,'pending',pending,'claimed',claimed)) FROM by_role), '[]'::json) AS by_role,
2075
- COALESCE((SELECT json_agg(json_build_object('type',type,'pending',pending,'claimed',claimed,'resolved',resolved)) FROM by_type), '[]'::json) AS by_type
2076
- FROM totals t
2097
+ COALESCE((SELECT SUM(pending)::int FROM pending_by_role), 0) AS pending,
2098
+ COALESCE((SELECT SUM(claimed)::int FROM pending_by_role), 0) AS claimed,
2099
+ (SELECT created FROM created_agg) AS created,
2100
+ COALESCE((SELECT SUM(resolved)::int FROM resolved_by_type), 0) AS resolved,
2101
+ COALESCE((SELECT json_agg(json_build_object('role',role,'pending',pending,'claimed',claimed))
2102
+ FROM pending_by_role WHERE role IS NOT NULL), '[]'::json) AS by_role,
2103
+ COALESCE((SELECT json_agg(json_build_object('type',t.type,'pending',t.pending,'claimed',t.claimed,'resolved',t.resolved))
2104
+ FROM (
2105
+ SELECT COALESCE(p.type, r.type) AS type,
2106
+ COALESCE(p.pending, 0) AS pending,
2107
+ COALESCE(p.claimed, 0) AS claimed,
2108
+ COALESCE(r.resolved, 0) AS resolved
2109
+ FROM pending_by_type p
2110
+ FULL OUTER JOIN resolved_by_type r ON r.type = p.type
2111
+ WHERE COALESCE(p.type, r.type) IS NOT NULL
2112
+ ) t), '[]'::json) AS by_type
2077
2113
  `, queryParams);
2078
2114
  const row = result.rows[0];
2079
2115
  return {
@@ -256,6 +256,14 @@ export interface ResolveEscalationParams {
256
256
  id: string;
257
257
  namespace?: string;
258
258
  resolverPayload?: Record<string, unknown>;
259
+ /**
260
+ * Merged (not replaced) into the resolved row's `metadata` in the same atomic
261
+ * UPDATE — and only on the winning resolve. Records "what actually happened"
262
+ * into the GIN-indexed (`@>`-queryable) surface alongside the creation metadata.
263
+ * Distinct from `resolverPayload`, which is delivered to the waiting workflow as
264
+ * `condition()`'s return value and is not GIN-indexed.
265
+ */
266
+ metadata?: Record<string, unknown>;
259
267
  }
260
268
  export interface ResolveByMetadataParams {
261
269
  key: string;
@@ -263,6 +271,12 @@ export interface ResolveByMetadataParams {
263
271
  namespace?: string;
264
272
  resolverPayload?: Record<string, unknown>;
265
273
  roles?: string[];
274
+ /**
275
+ * Merge patch applied to the matched row's `metadata` (shallow, not replaced) in
276
+ * the same atomic UPDATE. Note this is the resolution patch — distinct from the
277
+ * `key`/`value` selector used to find the row. See {@link ResolveEscalationParams.metadata}.
278
+ */
279
+ metadata?: Record<string, unknown>;
266
280
  }
267
281
  export interface EscalateToRoleParams {
268
282
  id: string;
@@ -289,6 +303,11 @@ export interface ResolveManyParams {
289
303
  ids: string[];
290
304
  namespace?: string;
291
305
  resolverPayload?: Record<string, unknown>;
306
+ /**
307
+ * Merged (not replaced) into every winning (still-pending) row's `metadata` in
308
+ * the single bulk UPDATE. See {@link ResolveEscalationParams.metadata}.
309
+ */
310
+ metadata?: Record<string, unknown>;
292
311
  }
293
312
  /**
294
313
  * Full-fidelity migration params. Extends `CreateEscalationParams` with:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",