@hotmeshio/hotmesh 0.25.5 → 0.26.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.
@@ -162,6 +162,18 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
162
162
  * picks it up — no intermediate table, no polling, fully transactional.
163
163
  */
164
164
  registerTimeHook(jobId: string, gId: string, activityId: string, _type: WorkListTaskType, deletionTime: number, dad: string, transaction?: ProviderTransaction): Promise<void>;
165
+ /**
166
+ * Disarms a scheduled timehook (soft delete) for one activity of a
167
+ * job — the mirror of registerTimeHook, called when the SIGNAL wins
168
+ * an SLA-gated wait so the armed timeout cannot fire against the
169
+ * settled workflow. Scoped by the jid index, then narrowed to the
170
+ * activity via the message metadata. Dimensional addressing is
171
+ * deliberately NOT matched: a job's waits on one activity are
172
+ * sequential (cycle N settles before cycle N+1 arms), so at most one
173
+ * timer per (jid, aid) is armed at a time, and the signal composite's
174
+ * address can differ from the stored one at cycle offsets.
175
+ */
176
+ expireTimeHook(jobId: string, activityId: string): Promise<number>;
165
177
  getNextTask(_listKey?: string): Promise<[
166
178
  listKey: string,
167
179
  jobId: string,
@@ -274,13 +286,14 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
274
286
  claimEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ClaimByMetadataParams): Promise<import('../../../../types/hmsh_escalations').ClaimByMetadataResult>;
275
287
  releaseEscalation(params: import('../../../../types/hmsh_escalations').ReleaseEscalationParams): Promise<import('../../../../types/hmsh_escalations').ReleaseEscalationResult>;
276
288
  /**
277
- * Executes a pre-built wake publish inside the currently open resolve
278
- * transaction, guarded by a SAVEPOINT so a wake failure can never roll
279
- * back the resolve itself. Returns true when the wake row committed
280
- * with the transaction; false means the caller should fall back to
281
- * post-commit delivery.
289
+ * Composes the wake as a data-modifying CTE appended to a settle
290
+ * statement (resolve/cancel). The INSERT fires iff the settle CTE
291
+ * (`fromCTE`) produced a row whose signal_key matches the wake one
292
+ * atomic statement, safe on any connection type (Client or Pool),
293
+ * with no transaction window for unrelated queries to interleave
294
+ * into. Appends the wake params to `params` in place.
282
295
  */
283
- private enqueueEscalationWake;
296
+ private composeEscalationWakeCTE;
284
297
  resolveEscalation(params: import('../../../../types/hmsh_escalations').ResolveEscalationParams, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').ResolveEscalationResult & {
285
298
  signalKey?: string | null;
286
299
  topic?: string | null;
@@ -302,7 +315,9 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
302
315
  topic?: string | null;
303
316
  wakeEnqueued?: boolean;
304
317
  }>;
305
- cancelEscalation(id: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult>;
318
+ cancelEscalation(id: string, namespace?: string, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult & {
319
+ wakeEnqueued?: boolean;
320
+ }>;
306
321
  escalateEscalationToRole(params: import('../../../../types/hmsh_escalations').EscalateToRoleParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
307
322
  updateEscalation(params: import('../../../../types/hmsh_escalations').UpdateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
308
323
  appendEscalationMilestones(params: import('../../../../types/hmsh_escalations').AppendMilestonesParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
@@ -316,5 +331,18 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
316
331
  escalationStats(params?: import('../../../../types/hmsh_escalations').StatsEscalationsParams): Promise<import('../../../../types/hmsh_escalations').EscalationStats>;
317
332
  listDistinctEscalationTypes(namespace?: string): Promise<string[]>;
318
333
  releaseExpiredEscalations(_namespace?: string): Promise<number>;
334
+ /**
335
+ * Deletes terminal escalation rows (`resolved`/`cancelled`/`expired`) whose
336
+ * `updated_at` is older than the given horizon. Terminal rows are inert —
337
+ * every state transition guards `status = 'pending'` — so pruning them is
338
+ * the engine-blessed retention path for the audit backlog.
339
+ *
340
+ * Single statement: the candidate SELECT (`FOR UPDATE SKIP LOCKED`) and the
341
+ * DELETE are one atomic unit, so concurrent pruners and readers never block
342
+ * each other and a row is either fully gone or fully present. `limit` bounds
343
+ * rows per call to keep vacuum pressure flat; callers loop until `deleted`
344
+ * returns 0.
345
+ */
346
+ pruneEscalations(params: import('../../../../types/hmsh_escalations').PruneEscalationsParams): Promise<import('../../../../types/hmsh_escalations').PruneEscalationsResult>;
319
347
  }
320
348
  export { PostgresStoreService };
@@ -814,43 +814,52 @@ class PostgresStoreService extends __1.StoreService {
814
814
  const wasInsert = lockResult.rows[0]?.inserted;
815
815
  const isPending = captured?.startsWith('$pending::');
816
816
  if (isPending && redelivery) {
817
- //consume the marker and commit its redelivery as ONE unit: the
818
- //pending wake becomes a durable engine stream message in the
819
- //same transaction that destroys the marker, so the wake
820
- //survives a crash at any instant. A crash before COMMIT leaves
821
- //the marker intact for the resume path to consume again.
822
- const pendingData = captured.slice('$pending::'.length);
823
- const message = JSON.stringify({
824
- type: 'webhook',
825
- status: 'success',
826
- code: 200,
827
- metadata: {
828
- guid: (0, utils_1.guid)(),
829
- aid: redelivery.aid,
830
- topic: redelivery.topic,
831
- },
832
- data: JSON.parse(pendingData),
833
- });
817
+ //consume the marker and commit its redelivery as ONE statement:
818
+ //the pending wake becomes a durable engine stream message in
819
+ //the same atomic unit that destroys the marker, so the wake
820
+ //survives a crash at any instant. Single-statement composition
821
+ //is safe on any connection type (Client or Pool) and cannot
822
+ //interleave with unrelated queries. The consume is guarded on
823
+ //the value still being a pending marker, and the redelivered
824
+ //payload is read from the row itself — a marker refreshed by a
825
+ //newer signal between capture and consume delivers the NEWER
826
+ //payload rather than a stale copy.
834
827
  const schemaName = this.kvsql().safeName(this.appId);
835
- await this.pgClient.query('BEGIN');
836
- try {
837
- await this.pgClient.query(`UPDATE ${tableName}
838
- SET value = $1, expiry = NOW() + INTERVAL '${delay} seconds'
839
- WHERE key = $2`, [jobId, storedKey]);
840
- await this.pgClient.query(`INSERT INTO ${schemaName}.engine_streams
828
+ const res = await this.pgClient.query(`WITH prev AS (
829
+ SELECT value FROM ${tableName}
830
+ WHERE key = $1 FOR UPDATE
831
+ ),
832
+ consumed AS (
833
+ UPDATE ${tableName} t
834
+ SET value = $2, expiry = NOW() + INTERVAL '${delay} seconds'
835
+ FROM prev
836
+ WHERE t.key = $1 AND prev.value LIKE '$pending::%'
837
+ RETURNING prev.value AS pending_value
838
+ )
839
+ INSERT INTO ${schemaName}.engine_streams
841
840
  (stream_name, message, priority)
842
- VALUES ($1, $2, 5)`, [this.appId, message]);
843
- await this.pgClient.query('COMMIT');
844
- }
845
- catch (redeliveryError) {
846
- await this.pgClient.query('ROLLBACK');
847
- throw redeliveryError;
841
+ SELECT $3,
842
+ json_build_object(
843
+ 'type', 'webhook',
844
+ 'status', 'success',
845
+ 'code', 200,
846
+ 'metadata', json_build_object(
847
+ 'guid', $4::text, 'aid', $5::text, 'topic', $6::text),
848
+ 'data', substr(consumed.pending_value, 11)::json
849
+ )::text,
850
+ 5
851
+ FROM consumed
852
+ RETURNING id`, [storedKey, jobId, this.appId, (0, utils_1.guid)(), redelivery.aid, redelivery.topic]);
853
+ if ((res.rowCount ?? 0) > 0) {
854
+ this.logger.warn('hook-signal-pending-redelivered', {
855
+ key: signalKey,
856
+ topic: redelivery.topic,
857
+ });
858
+ return { success: true };
848
859
  }
849
- this.logger.warn('hook-signal-pending-redelivered', {
850
- key: signalKey,
851
- topic: redelivery.topic,
852
- });
853
- return { success: true };
860
+ //the marker vanished between capture and consume (a concurrent
861
+ //duplicate Leg1 consumed it) — idempotent no-op
862
+ return { success: false };
854
863
  }
855
864
  if (!wasInsert) {
856
865
  //step 2: row existed — overwrite with hook value
@@ -1022,10 +1031,12 @@ class PostgresStoreService extends __1.StoreService {
1022
1031
  metadata: { guid: (0, utils_1.guid)(), jid: jobId, gid: gId, dad: msgDad, aid },
1023
1032
  data: { timestamp: Date.now() },
1024
1033
  });
1034
+ //jid column stamped so job-scoped expiry (interrupt purge, timeout
1035
+ //disarm) can address armed timers through the partial jid index
1025
1036
  const sql = `INSERT INTO ${schemaName}.engine_streams
1026
- (stream_name, message, priority, visible_at)
1027
- VALUES ($1, $2, 5, NOW() + INTERVAL '${Math.max(delayMs, 0)} milliseconds')`;
1028
- const params = [this.appId, message];
1037
+ (stream_name, jid, message, priority, visible_at)
1038
+ VALUES ($1, $2, $3, 5, NOW() + INTERVAL '${Math.max(delayMs, 0)} milliseconds')`;
1039
+ const params = [this.appId, jobId, message];
1029
1040
  if (transaction && typeof transaction.addCommand === 'function') {
1030
1041
  transaction.addCommand(sql, params);
1031
1042
  }
@@ -1033,6 +1044,26 @@ class PostgresStoreService extends __1.StoreService {
1033
1044
  await this.pgClient.query(sql, params);
1034
1045
  }
1035
1046
  }
1047
+ /**
1048
+ * Disarms a scheduled timehook (soft delete) for one activity of a
1049
+ * job — the mirror of registerTimeHook, called when the SIGNAL wins
1050
+ * an SLA-gated wait so the armed timeout cannot fire against the
1051
+ * settled workflow. Scoped by the jid index, then narrowed to the
1052
+ * activity via the message metadata. Dimensional addressing is
1053
+ * deliberately NOT matched: a job's waits on one activity are
1054
+ * sequential (cycle N settles before cycle N+1 arms), so at most one
1055
+ * timer per (jid, aid) is armed at a time, and the signal composite's
1056
+ * address can differ from the stored one at cycle offsets.
1057
+ */
1058
+ async expireTimeHook(jobId, activityId) {
1059
+ const schemaName = this.kvsql().safeName(this.appId);
1060
+ const res = await this.pgClient.query(`UPDATE ${schemaName}.engine_streams
1061
+ SET expired_at = NOW()
1062
+ WHERE jid = $1 AND expired_at IS NULL AND visible_at > NOW()
1063
+ AND (message::jsonb ->> 'type') = 'timehook'
1064
+ AND (message::jsonb -> 'metadata' ->> 'aid') = $2`, [jobId, activityId]);
1065
+ return res.rowCount ?? 0;
1066
+ }
1036
1067
  async getNextTask(_listKey) {
1037
1068
  //no-op: time hooks are now engine_streams messages with future visible_at.
1038
1069
  //the engine's normal dequeue handles them when they become visible.
@@ -1832,85 +1863,95 @@ class PostgresStoreService extends __1.StoreService {
1832
1863
  return { ok: true, entry: row.entry_json };
1833
1864
  }
1834
1865
  /**
1835
- * Executes a pre-built wake publish inside the currently open resolve
1836
- * transaction, guarded by a SAVEPOINT so a wake failure can never roll
1837
- * back the resolve itself. Returns true when the wake row committed
1838
- * with the transaction; false means the caller should fall back to
1839
- * post-commit delivery.
1866
+ * Composes the wake as a data-modifying CTE appended to a settle
1867
+ * statement (resolve/cancel). The INSERT fires iff the settle CTE
1868
+ * (`fromCTE`) produced a row whose signal_key matches the wake one
1869
+ * atomic statement, safe on any connection type (Client or Pool),
1870
+ * with no transaction window for unrelated queries to interleave
1871
+ * into. Appends the wake params to `params` in place.
1840
1872
  */
1841
- async enqueueEscalationWake(wakeCommand, signalKey, escalationId) {
1842
- if (!wakeCommand || !signalKey || wakeCommand.forSignalKey !== signalKey) {
1843
- return false;
1844
- }
1845
- await this.pgClient.query('SAVEPOINT escalation_wake');
1846
- try {
1847
- await this.pgClient.query(wakeCommand.sql, wakeCommand.params);
1848
- return true;
1849
- }
1850
- catch (error) {
1851
- await this.pgClient.query('ROLLBACK TO SAVEPOINT escalation_wake');
1852
- this.logger.warn('escalation-wake-enqueue-error', {
1853
- escalationId,
1854
- error: error.message,
1855
- });
1856
- return false;
1873
+ composeEscalationWakeCTE(wakeCommand, params, fromCTE) {
1874
+ if (!wakeCommand) {
1875
+ return { wakeCTE: '', wakeCount: '0::int AS wake_count' };
1857
1876
  }
1877
+ const schemaName = this.kvsql().safeName(this.appId);
1878
+ const base = params.length;
1879
+ params.push(this.appId, wakeCommand.message, wakeCommand.forSignalKey);
1880
+ return {
1881
+ wakeCTE: `,
1882
+ wake AS (
1883
+ INSERT INTO ${schemaName}.engine_streams (stream_name, message, priority)
1884
+ SELECT $${base + 1}, $${base + 2}, 5 FROM ${fromCTE}
1885
+ WHERE ${fromCTE}.signal_key = $${base + 3}
1886
+ RETURNING id
1887
+ )`,
1888
+ wakeCount: '(SELECT COUNT(*) FROM wake)::int AS wake_count',
1889
+ };
1858
1890
  }
1859
1891
  async resolveEscalation(params, wakeCommand) {
1860
1892
  const { id, namespace, resolverPayload, metadata } = params;
1861
1893
  const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1862
- // metaJson is bound at a fixed index; the CASE no-ops when null so resolution
1863
- // can merge into the GIN-indexed metadata without disturbing the param layout.
1864
1894
  const metaJson = metadata ? JSON.stringify(metadata) : null;
1865
- // Explicit transaction: FOR UPDATE locks the row; WHERE guard on UPDATE is the TOCTOU
1866
- // barrier a concurrent caller whose UPDATE matches 0 rows sees 'already-resolved'.
1867
- // On crash before COMMIT neither write lands; on crash after COMMIT both are durable.
1868
- // The resolved row's signal_key is the durable proof for recovery sweeps.
1869
- await this.pgClient.query('BEGIN');
1870
- try {
1871
- const lockResult = await this.pgClient.query(`SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1872
- WHERE id = $1 ${namespace ? 'AND namespace = $2' : ''} FOR UPDATE`, namespace ? [id, namespace] : [id]);
1873
- if (!lockResult.rows[0]) {
1874
- await this.pgClient.query('ROLLBACK');
1875
- return { ok: false, reason: 'not-found' };
1876
- }
1877
- const { signal_key, topic, status } = lockResult.rows[0];
1878
- if (status === 'cancelled') {
1879
- await this.pgClient.query('ROLLBACK');
1895
+ //single statement: FOR UPDATE lock, TOCTOU-guarded UPDATE, and the
1896
+ //wake INSERT are one atomic unit. The wake commits WITH the
1897
+ //resolve a crash after the statement leaves both the resolved row
1898
+ //and its wake message durable; before, neither. The engine_streams
1899
+ //INSERT trigger emits the delivery NOTIFY on commit. Safe on any
1900
+ //connection type; no window for unrelated queries to interleave.
1901
+ const sqlParams = namespace
1902
+ ? [id, payloadJson, metaJson, namespace]
1903
+ : [id, payloadJson, metaJson];
1904
+ const { wakeCTE, wakeCount } = this.composeEscalationWakeCTE(wakeCommand, sqlParams, 'resolved');
1905
+ const result = await this.pgClient.query(`
1906
+ WITH target AS MATERIALIZED (
1907
+ SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1908
+ WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''}
1909
+ LIMIT 1 FOR UPDATE
1910
+ ),
1911
+ resolved AS (
1912
+ UPDATE public.hmsh_escalations e
1913
+ SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1914
+ metadata = CASE WHEN $3::jsonb IS NOT NULL
1915
+ THEN COALESCE(e.metadata, '{}'::jsonb) || $3::jsonb
1916
+ ELSE e.metadata END,
1917
+ updated_at = NOW()
1918
+ FROM target
1919
+ WHERE e.id = target.id AND target.status = 'pending'
1920
+ RETURNING e.*
1921
+ )${wakeCTE}
1922
+ SELECT t.id, t.status AS prior_status, t.signal_key, t.topic,
1923
+ CASE
1924
+ WHEN r.id IS NOT NULL THEN 'resolved'
1925
+ WHEN t.id IS NULL THEN 'not-found'
1926
+ ELSE 'blocked'
1927
+ END AS outcome,
1928
+ row_to_json(r.*) AS entry_json,
1929
+ ${wakeCount}
1930
+ FROM (SELECT * FROM target) t
1931
+ FULL OUTER JOIN (SELECT * FROM resolved) r ON r.id = t.id
1932
+ `, sqlParams);
1933
+ const row = result.rows[0];
1934
+ if (!row || row.outcome === 'not-found')
1935
+ return { ok: false, reason: 'not-found' };
1936
+ if (row.outcome === 'blocked') {
1937
+ if (row.prior_status === 'cancelled')
1880
1938
  return { ok: false, reason: 'already-cancelled' };
1881
- }
1882
- if (status === 'expired') {
1939
+ if (row.prior_status === 'expired') {
1883
1940
  // The wait's SLA timer fired first and the workflow resumed with
1884
1941
  // false — the resolver payload has nowhere to go. Name it, so the
1885
1942
  // operator learns the deadline passed rather than believing the
1886
1943
  // resolution landed.
1887
- await this.pgClient.query('ROLLBACK');
1888
1944
  return { ok: false, reason: 'already-expired' };
1889
1945
  }
1890
- const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1891
- SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1892
- metadata = CASE WHEN $3::jsonb IS NOT NULL
1893
- THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
1894
- ELSE metadata END,
1895
- updated_at = NOW()
1896
- WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''} AND status = 'pending'
1897
- RETURNING *`, namespace ? [id, payloadJson, metaJson, namespace] : [id, payloadJson, metaJson]);
1898
- if (!updateResult.rows[0]) {
1899
- await this.pgClient.query('ROLLBACK');
1900
- return { ok: false, reason: 'already-resolved' };
1901
- }
1902
- //the wake commits WITH the resolve: a crash after COMMIT leaves both
1903
- //the resolved row and its wake message durable; a crash before
1904
- //leaves neither. The engine_streams INSERT trigger emits the
1905
- //delivery NOTIFY on commit.
1906
- const wakeEnqueued = await this.enqueueEscalationWake(wakeCommand, signal_key, id);
1907
- await this.pgClient.query('COMMIT');
1908
- return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic, wakeEnqueued };
1909
- }
1910
- catch (e) {
1911
- await this.pgClient.query('ROLLBACK');
1912
- throw e;
1946
+ return { ok: false, reason: 'already-resolved' };
1913
1947
  }
1948
+ return {
1949
+ ok: true,
1950
+ entry: row.entry_json,
1951
+ signalKey: row.signal_key,
1952
+ topic: row.topic,
1953
+ wakeEnqueued: row.wake_count > 0,
1954
+ };
1914
1955
  }
1915
1956
  /**
1916
1957
  * Non-locking preview of the row `resolveEscalationByMetadata` would
@@ -1938,46 +1979,66 @@ class PostgresStoreService extends __1.StoreService {
1938
1979
  const filter = JSON.stringify({ [key]: value });
1939
1980
  const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
1940
1981
  const metaJson = metadata ? JSON.stringify(metadata) : null;
1941
- await this.pgClient.query('BEGIN');
1942
- try {
1943
- const lockResult = await this.pgClient.query(`SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1944
- WHERE ${namespace ? 'namespace = $3 AND' : ''}
1945
- metadata @> $1::jsonb
1946
- AND ($2::text[] IS NULL OR role = ANY($2::text[]))
1947
- AND status IN ('pending', 'cancelled')
1948
- ORDER BY priority ASC, created_at ASC
1949
- LIMIT 1 FOR UPDATE`, namespace ? [filter, roles ?? null, namespace] : [filter, roles ?? null]);
1950
- if (!lockResult.rows[0]) {
1951
- await this.pgClient.query('ROLLBACK');
1952
- return { ok: false, reason: 'not-found' };
1953
- }
1954
- const { id, signal_key, topic, status } = lockResult.rows[0];
1955
- if (status === 'cancelled') {
1956
- await this.pgClient.query('ROLLBACK');
1982
+ //single statement — see resolveEscalation for the atomicity contract
1983
+ const sqlParams = namespace
1984
+ ? [filter, roles ?? null, payloadJson, metaJson, namespace]
1985
+ : [filter, roles ?? null, payloadJson, metaJson];
1986
+ const { wakeCTE, wakeCount } = this.composeEscalationWakeCTE(wakeCommand, sqlParams, 'resolved');
1987
+ const result = await this.pgClient.query(`
1988
+ WITH target AS MATERIALIZED (
1989
+ SELECT id, signal_key, topic, status FROM public.hmsh_escalations
1990
+ WHERE ${namespace ? 'namespace = $5 AND' : ''}
1991
+ metadata @> $1::jsonb
1992
+ AND ($2::text[] IS NULL OR role = ANY($2::text[]))
1993
+ AND status IN ('pending', 'cancelled')
1994
+ ORDER BY priority ASC, created_at ASC
1995
+ LIMIT 1 FOR UPDATE
1996
+ ),
1997
+ resolved AS (
1998
+ UPDATE public.hmsh_escalations e
1999
+ SET status = 'resolved', resolved_at = NOW(), resolver_payload = $3,
2000
+ metadata = CASE WHEN $4::jsonb IS NOT NULL
2001
+ THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb
2002
+ ELSE e.metadata END,
2003
+ updated_at = NOW()
2004
+ FROM target
2005
+ WHERE e.id = target.id AND target.status = 'pending'
2006
+ RETURNING e.*
2007
+ )${wakeCTE}
2008
+ SELECT t.id, t.status AS prior_status, t.signal_key, t.topic,
2009
+ CASE
2010
+ WHEN r.id IS NOT NULL THEN 'resolved'
2011
+ WHEN t.id IS NULL THEN 'not-found'
2012
+ ELSE 'blocked'
2013
+ END AS outcome,
2014
+ row_to_json(r.*) AS entry_json,
2015
+ ${wakeCount}
2016
+ FROM (SELECT * FROM target) t
2017
+ FULL OUTER JOIN (SELECT * FROM resolved) r ON r.id = t.id
2018
+ `, sqlParams);
2019
+ const row = result.rows[0];
2020
+ if (!row || row.outcome === 'not-found')
2021
+ return { ok: false, reason: 'not-found' };
2022
+ if (row.outcome === 'blocked') {
2023
+ if (row.prior_status === 'cancelled')
1957
2024
  return { ok: false, reason: 'already-cancelled' };
1958
- }
1959
- const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1960
- SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1961
- metadata = CASE WHEN $3::jsonb IS NOT NULL
1962
- THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
1963
- ELSE metadata END,
1964
- updated_at = NOW()
1965
- WHERE id = $1 AND status = 'pending'
1966
- RETURNING *`, [id, payloadJson, metaJson]);
1967
- if (!updateResult.rows[0]) {
1968
- await this.pgClient.query('ROLLBACK');
1969
- return { ok: false, reason: 'already-resolved' };
1970
- }
1971
- const wakeEnqueued = await this.enqueueEscalationWake(wakeCommand, signal_key, id);
1972
- await this.pgClient.query('COMMIT');
1973
- return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic, wakeEnqueued };
1974
- }
1975
- catch (e) {
1976
- await this.pgClient.query('ROLLBACK');
1977
- throw e;
2025
+ return { ok: false, reason: 'already-resolved' };
1978
2026
  }
2027
+ return {
2028
+ ok: true,
2029
+ entry: row.entry_json,
2030
+ signalKey: row.signal_key,
2031
+ topic: row.topic,
2032
+ wakeEnqueued: row.wake_count > 0,
2033
+ };
1979
2034
  }
1980
- async cancelEscalation(id, namespace) {
2035
+ async cancelEscalation(id, namespace, wakeCommand) {
2036
+ //single statement: the cancellation wake is a data-modifying CTE
2037
+ //that fires iff the cancel lands AND the row's signal_key matches
2038
+ //the wake — atomic on any connection type (Client or Pool), with
2039
+ //no BEGIN/COMMIT window for unrelated queries to interleave into
2040
+ const params = namespace ? [id, namespace] : [id];
2041
+ const { wakeCTE, wakeCount } = this.composeEscalationWakeCTE(wakeCommand, params, 'cancelled');
1981
2042
  const result = await this.pgClient.query(`
1982
2043
  WITH target AS MATERIALIZED (
1983
2044
  SELECT id, status FROM public.hmsh_escalations
@@ -1991,23 +2052,24 @@ class PostgresStoreService extends __1.StoreService {
1991
2052
  WHERE public.hmsh_escalations.id = target.id
1992
2053
  AND target.status = 'pending'
1993
2054
  RETURNING public.hmsh_escalations.*
1994
- )
2055
+ )${wakeCTE}
1995
2056
  SELECT t.id, t.status AS prior_status,
1996
2057
  CASE
1997
2058
  WHEN c.id IS NOT NULL THEN 'cancelled'
1998
2059
  WHEN t.id IS NULL THEN 'not-found'
1999
2060
  ELSE 'already-terminal'
2000
2061
  END AS outcome,
2001
- row_to_json(c.*) AS entry_json
2062
+ row_to_json(c.*) AS entry_json,
2063
+ ${wakeCount}
2002
2064
  FROM (SELECT * FROM target) t
2003
2065
  FULL OUTER JOIN (SELECT * FROM cancelled) c ON c.id = t.id
2004
- `, namespace ? [id, namespace] : [id]);
2066
+ `, params);
2005
2067
  if (!result.rows[0] || result.rows[0].outcome === 'not-found')
2006
2068
  return { ok: false, reason: 'not-found' };
2007
2069
  if (result.rows[0].outcome === 'already-terminal')
2008
2070
  return { ok: false, reason: 'already-terminal' };
2009
2071
  const entry = result.rows[0].entry_json;
2010
- return { ok: true, entry };
2072
+ return { ok: true, entry, wakeEnqueued: result.rows[0].wake_count > 0 };
2011
2073
  }
2012
2074
  async escalateEscalationToRole(params) {
2013
2075
  const { id, targetRole, namespace } = params;
@@ -2156,6 +2218,7 @@ class PostgresStoreService extends __1.StoreService {
2156
2218
  WHERE id = ANY($2::uuid[])
2157
2219
  ${namespace ? 'AND namespace = $4' : ''}
2158
2220
  AND status = 'pending'
2221
+ AND signal_key IS NULL
2159
2222
  RETURNING *`, namespace ? [payloadJson, ids, metaJson, namespace] : [payloadJson, ids, metaJson]);
2160
2223
  return result.rows;
2161
2224
  }
@@ -2255,5 +2318,44 @@ class PostgresStoreService extends __1.StoreService {
2255
2318
  // assigned_until, so no background sweep is needed to release expired claims.
2256
2319
  return 0;
2257
2320
  }
2321
+ /**
2322
+ * Deletes terminal escalation rows (`resolved`/`cancelled`/`expired`) whose
2323
+ * `updated_at` is older than the given horizon. Terminal rows are inert —
2324
+ * every state transition guards `status = 'pending'` — so pruning them is
2325
+ * the engine-blessed retention path for the audit backlog.
2326
+ *
2327
+ * Single statement: the candidate SELECT (`FOR UPDATE SKIP LOCKED`) and the
2328
+ * DELETE are one atomic unit, so concurrent pruners and readers never block
2329
+ * each other and a row is either fully gone or fully present. `limit` bounds
2330
+ * rows per call to keep vacuum pressure flat; callers loop until `deleted`
2331
+ * returns 0.
2332
+ */
2333
+ async pruneEscalations(params) {
2334
+ const TERMINAL = ['resolved', 'cancelled', 'expired'];
2335
+ const statuses = params.statuses?.length
2336
+ ? params.statuses.filter((s) => TERMINAL.includes(s))
2337
+ : [...TERMINAL];
2338
+ if (!statuses.length)
2339
+ return { deleted: 0 };
2340
+ const limit = Math.max(1, Math.min(params.limit ?? 10000, 100000));
2341
+ const values = [statuses, params.olderThan, limit];
2342
+ let nsClause = '';
2343
+ if (params.namespace) {
2344
+ values.push(params.namespace);
2345
+ nsClause = `AND namespace = $${values.length}`;
2346
+ }
2347
+ const result = await this.pgClient.query(`WITH doomed AS (
2348
+ SELECT ctid FROM public.hmsh_escalations
2349
+ WHERE status = ANY($1::text[])
2350
+ AND status IN ('resolved', 'cancelled', 'expired')
2351
+ AND updated_at < NOW() - $2::interval
2352
+ ${nsClause}
2353
+ LIMIT $3
2354
+ FOR UPDATE SKIP LOCKED
2355
+ )
2356
+ DELETE FROM public.hmsh_escalations e
2357
+ USING doomed WHERE e.ctid = doomed.ctid`, values);
2358
+ return { deleted: result.rowCount ?? 0 };
2359
+ }
2258
2360
  }
2259
2361
  exports.PostgresStoreService = PostgresStoreService;