@hotmeshio/hotmesh 0.10.2 → 0.12.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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/build/modules/enums.d.ts +1 -0
  3. package/build/modules/enums.js +3 -1
  4. package/build/modules/errors.d.ts +2 -0
  5. package/build/modules/errors.js +2 -0
  6. package/build/modules/key.js +3 -2
  7. package/build/package.json +2 -2
  8. package/build/services/activities/worker.js +10 -0
  9. package/build/services/dba/index.d.ts +2 -1
  10. package/build/services/dba/index.js +11 -2
  11. package/build/services/durable/client.js +6 -1
  12. package/build/services/durable/exporter.d.ts +15 -0
  13. package/build/services/durable/exporter.js +384 -5
  14. package/build/services/durable/schemas/factory.d.ts +1 -1
  15. package/build/services/durable/schemas/factory.js +27 -4
  16. package/build/services/durable/worker.d.ts +2 -2
  17. package/build/services/durable/worker.js +15 -9
  18. package/build/services/durable/workflow/context.js +2 -0
  19. package/build/services/durable/workflow/execChild.js +5 -2
  20. package/build/services/durable/workflow/hook.js +6 -0
  21. package/build/services/durable/workflow/proxyActivities.js +3 -4
  22. package/build/services/engine/index.d.ts +2 -2
  23. package/build/services/engine/index.js +10 -5
  24. package/build/services/exporter/index.d.ts +16 -2
  25. package/build/services/exporter/index.js +76 -0
  26. package/build/services/hotmesh/index.d.ts +2 -2
  27. package/build/services/hotmesh/index.js +2 -2
  28. package/build/services/router/config/index.d.ts +2 -2
  29. package/build/services/router/config/index.js +2 -1
  30. package/build/services/router/consumption/index.js +80 -5
  31. package/build/services/store/index.d.ts +52 -0
  32. package/build/services/store/providers/postgres/exporter-sql.d.ts +40 -0
  33. package/build/services/store/providers/postgres/exporter-sql.js +92 -0
  34. package/build/services/store/providers/postgres/kvtables.js +6 -0
  35. package/build/services/store/providers/postgres/postgres.d.ts +42 -0
  36. package/build/services/store/providers/postgres/postgres.js +151 -0
  37. package/build/services/stream/index.d.ts +1 -0
  38. package/build/services/stream/providers/postgres/kvtables.d.ts +1 -1
  39. package/build/services/stream/providers/postgres/kvtables.js +235 -82
  40. package/build/services/stream/providers/postgres/lifecycle.d.ts +4 -3
  41. package/build/services/stream/providers/postgres/lifecycle.js +6 -5
  42. package/build/services/stream/providers/postgres/messages.d.ts +14 -6
  43. package/build/services/stream/providers/postgres/messages.js +153 -76
  44. package/build/services/stream/providers/postgres/notifications.d.ts +5 -2
  45. package/build/services/stream/providers/postgres/notifications.js +39 -35
  46. package/build/services/stream/providers/postgres/postgres.d.ts +21 -118
  47. package/build/services/stream/providers/postgres/postgres.js +87 -140
  48. package/build/services/stream/providers/postgres/scout.js +2 -2
  49. package/build/services/stream/providers/postgres/stats.js +3 -2
  50. package/build/services/stream/registry.d.ts +62 -0
  51. package/build/services/stream/registry.js +198 -0
  52. package/build/services/worker/index.js +20 -6
  53. package/build/types/durable.d.ts +6 -1
  54. package/build/types/error.d.ts +2 -0
  55. package/build/types/exporter.d.ts +84 -0
  56. package/build/types/hotmesh.d.ts +7 -1
  57. package/build/types/index.d.ts +1 -1
  58. package/build/types/stream.d.ts +2 -0
  59. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.retryMessages = exports.ackAndDelete = exports.deleteMessages = exports.acknowledgeMessages = exports.fetchMessages = exports.buildPublishSQL = exports.publishMessages = void 0;
3
+ exports.retryMessages = exports.deadLetterMessages = exports.ackAndDelete = exports.deleteMessages = exports.acknowledgeMessages = exports.fetchMessages = exports.buildPublishSQL = exports.publishMessages = void 0;
4
4
  const utils_1 = require("../../../../modules/utils");
5
5
  /**
6
6
  * Publish messages to a stream. Can be used within a transaction.
@@ -8,11 +8,10 @@ const utils_1 = require("../../../../modules/utils");
8
8
  * When a transaction is provided, the SQL is added to the transaction
9
9
  * and executed atomically with other operations.
10
10
  */
11
- async function publishMessages(client, tableName, streamName, messages, options, logger) {
12
- const { sql, params } = buildPublishSQL(tableName, streamName, messages, options);
11
+ async function publishMessages(client, tableName, streamName, isEngine, messages, options, logger) {
12
+ const { sql, params } = buildPublishSQL(tableName, streamName, isEngine, messages, options);
13
13
  if (options?.transaction &&
14
14
  typeof options.transaction.addCommand === 'function') {
15
- // Add to transaction and return the transaction object
16
15
  options.transaction.addCommand(sql, params, 'array', (rows) => rows.map((row) => row.id.toString()));
17
16
  return options.transaction;
18
17
  }
@@ -36,11 +35,11 @@ async function publishMessages(client, tableName, streamName, messages, options,
36
35
  exports.publishMessages = publishMessages;
37
36
  /**
38
37
  * Build SQL for publishing messages with retry policies and visibility delays.
39
- * Optimizes the INSERT statement based on whether retry config is present.
38
+ * Routes to engine_streams or worker_streams based on isEngine flag.
39
+ * Worker messages include a workflow_name column extracted from metadata.wfn.
40
40
  */
41
- function buildPublishSQL(tableName, streamName, messages, options) {
42
- const groupName = streamName.endsWith(':') ? 'ENGINE' : 'WORKER';
43
- // Parse messages to extract retry config and visibility options
41
+ function buildPublishSQL(tableName, streamName, isEngine, messages, options) {
42
+ // Parse messages to extract retry config, visibility options, and workflow name
44
43
  const parsedMessages = messages.map(msg => {
45
44
  const data = JSON.parse(msg);
46
45
  const retryConfig = data._streamRetryConfig;
@@ -50,6 +49,13 @@ function buildPublishSQL(tableName, streamName, messages, options) {
50
49
  delete data._streamRetryConfig;
51
50
  delete data._visibilityDelayMs;
52
51
  delete data._retryAttempt;
52
+ // Extract metadata for worker stream columns
53
+ const workflowName = data.metadata?.wfn || '';
54
+ const jid = data.metadata?.jid || '';
55
+ const aid = data.metadata?.aid || '';
56
+ const dad = data.metadata?.dad || '';
57
+ const msgType = data.type || '';
58
+ const topic = data.metadata?.topic || '';
53
59
  // Determine if this message has explicit retry config
54
60
  const hasExplicitConfig = (retryConfig && 'max_retry_attempts' in retryConfig) || options?.retryPolicy;
55
61
  let normalizedPolicy = null;
@@ -69,63 +75,111 @@ function buildPublishSQL(tableName, streamName, messages, options) {
69
75
  retryPolicy: normalizedPolicy,
70
76
  visibilityDelayMs: visibilityDelayMs || 0,
71
77
  retryAttempt: retryAttempt || 0,
78
+ workflowName,
79
+ jid,
80
+ aid,
81
+ dad,
82
+ msgType,
83
+ topic,
72
84
  };
73
85
  });
74
- const params = [streamName, groupName];
86
+ const params = [streamName];
75
87
  let valuesClauses = [];
76
88
  let insertColumns;
77
- // Check if ALL messages have explicit config or ALL don't
78
89
  const allHaveConfig = parsedMessages.every(pm => pm.hasExplicitConfig);
79
90
  const noneHaveConfig = parsedMessages.every(pm => !pm.hasExplicitConfig);
80
91
  const hasVisibilityDelays = parsedMessages.some(pm => pm.visibilityDelayMs > 0);
81
- if (noneHaveConfig && !hasVisibilityDelays) {
82
- // Omit retry columns entirely - let DB defaults apply
83
- insertColumns = '(stream_name, group_name, message)';
84
- parsedMessages.forEach((pm, idx) => {
85
- const base = idx * 1;
86
- valuesClauses.push(`($1, $2, $${base + 3})`);
87
- params.push(pm.message);
88
- });
89
- }
90
- else if (noneHaveConfig && hasVisibilityDelays) {
91
- // Only visibility delays, no retry config
92
- insertColumns = '(stream_name, group_name, message, visible_at, retry_attempt)';
93
- parsedMessages.forEach((pm, idx) => {
94
- const base = idx * 2;
95
- if (pm.visibilityDelayMs > 0) {
96
- const visibleAtSQL = `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`;
97
- valuesClauses.push(`($1, $2, $${base + 3}, ${visibleAtSQL}, $${base + 4})`);
98
- params.push(pm.message, pm.retryAttempt);
99
- }
100
- else {
101
- valuesClauses.push(`($1, $2, $${base + 3}, DEFAULT, $${base + 4})`);
102
- params.push(pm.message, pm.retryAttempt);
103
- }
104
- });
92
+ if (isEngine) {
93
+ // Engine table: no group_name, no workflow_name
94
+ if (noneHaveConfig && !hasVisibilityDelays) {
95
+ insertColumns = '(stream_name, message)';
96
+ parsedMessages.forEach((pm, idx) => {
97
+ const base = idx * 1;
98
+ valuesClauses.push(`($1, $${base + 2})`);
99
+ params.push(pm.message);
100
+ });
101
+ }
102
+ else if (noneHaveConfig && hasVisibilityDelays) {
103
+ insertColumns = '(stream_name, message, visible_at, retry_attempt)';
104
+ parsedMessages.forEach((pm, idx) => {
105
+ const base = idx * 2;
106
+ if (pm.visibilityDelayMs > 0) {
107
+ const visibleAtSQL = `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`;
108
+ valuesClauses.push(`($1, $${base + 2}, ${visibleAtSQL}, $${base + 3})`);
109
+ params.push(pm.message, pm.retryAttempt);
110
+ }
111
+ else {
112
+ valuesClauses.push(`($1, $${base + 2}, DEFAULT, $${base + 3})`);
113
+ params.push(pm.message, pm.retryAttempt);
114
+ }
115
+ });
116
+ }
117
+ else {
118
+ insertColumns = '(stream_name, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)';
119
+ parsedMessages.forEach((pm) => {
120
+ const visibleAtClause = pm.visibilityDelayMs > 0
121
+ ? `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`
122
+ : 'DEFAULT';
123
+ if (pm.hasExplicitConfig) {
124
+ const paramOffset = params.length + 1;
125
+ valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, ${visibleAtClause}, $${paramOffset + 4})`);
126
+ params.push(pm.message, pm.retryPolicy.max_retry_attempts, pm.retryPolicy.backoff_coefficient, pm.retryPolicy.maximum_interval_seconds, pm.retryAttempt);
127
+ }
128
+ else {
129
+ const paramOffset = params.length + 1;
130
+ valuesClauses.push(`($1, $${paramOffset}, DEFAULT, DEFAULT, DEFAULT, ${visibleAtClause}, $${paramOffset + 1})`);
131
+ params.push(pm.message, pm.retryAttempt);
132
+ }
133
+ });
134
+ }
105
135
  }
106
136
  else {
107
- // Include retry columns and optionally visibility
108
- insertColumns = '(stream_name, group_name, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)';
109
- parsedMessages.forEach((pm, idx) => {
110
- const visibleAtClause = pm.visibilityDelayMs > 0
111
- ? `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`
112
- : 'DEFAULT';
113
- if (pm.hasExplicitConfig) {
114
- const paramOffset = params.length + 1; // Current param count + 1 for next param
115
- valuesClauses.push(`($1, $2, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, ${visibleAtClause}, $${paramOffset + 4})`);
116
- params.push(pm.message, pm.retryPolicy.max_retry_attempts, pm.retryPolicy.backoff_coefficient, pm.retryPolicy.maximum_interval_seconds, pm.retryAttempt);
117
- }
118
- else {
119
- // This message doesn't have config but others do - use DEFAULT keyword
137
+ // Worker table: includes workflow_name + export fidelity columns, no group_name
138
+ if (noneHaveConfig && !hasVisibilityDelays) {
139
+ insertColumns = '(stream_name, workflow_name, jid, aid, dad, msg_type, topic, message)';
140
+ parsedMessages.forEach((pm) => {
120
141
  const paramOffset = params.length + 1;
121
- valuesClauses.push(`($1, $2, $${paramOffset}, DEFAULT, DEFAULT, DEFAULT, ${visibleAtClause}, $${paramOffset + 1})`);
122
- params.push(pm.message, pm.retryAttempt);
123
- }
124
- });
142
+ valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, $${paramOffset + 5}, $${paramOffset + 6})`);
143
+ params.push(pm.workflowName, pm.jid, pm.aid, pm.dad, pm.msgType, pm.topic, pm.message);
144
+ });
145
+ }
146
+ else if (noneHaveConfig && hasVisibilityDelays) {
147
+ insertColumns = '(stream_name, workflow_name, jid, aid, dad, msg_type, topic, message, visible_at, retry_attempt)';
148
+ parsedMessages.forEach((pm) => {
149
+ const paramOffset = params.length + 1;
150
+ if (pm.visibilityDelayMs > 0) {
151
+ const visibleAtSQL = `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`;
152
+ valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, $${paramOffset + 5}, $${paramOffset + 6}, ${visibleAtSQL}, $${paramOffset + 7})`);
153
+ params.push(pm.workflowName, pm.jid, pm.aid, pm.dad, pm.msgType, pm.topic, pm.message, pm.retryAttempt);
154
+ }
155
+ else {
156
+ valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, $${paramOffset + 5}, $${paramOffset + 6}, DEFAULT, $${paramOffset + 7})`);
157
+ params.push(pm.workflowName, pm.jid, pm.aid, pm.dad, pm.msgType, pm.topic, pm.message, pm.retryAttempt);
158
+ }
159
+ });
160
+ }
161
+ else {
162
+ insertColumns = '(stream_name, workflow_name, jid, aid, dad, msg_type, topic, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)';
163
+ parsedMessages.forEach((pm) => {
164
+ const visibleAtClause = pm.visibilityDelayMs > 0
165
+ ? `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`
166
+ : 'DEFAULT';
167
+ if (pm.hasExplicitConfig) {
168
+ const paramOffset = params.length + 1;
169
+ valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, $${paramOffset + 5}, $${paramOffset + 6}, $${paramOffset + 7}, $${paramOffset + 8}, $${paramOffset + 9}, ${visibleAtClause}, $${paramOffset + 10})`);
170
+ params.push(pm.workflowName, pm.jid, pm.aid, pm.dad, pm.msgType, pm.topic, pm.message, pm.retryPolicy.max_retry_attempts, pm.retryPolicy.backoff_coefficient, pm.retryPolicy.maximum_interval_seconds, pm.retryAttempt);
171
+ }
172
+ else {
173
+ const paramOffset = params.length + 1;
174
+ valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, $${paramOffset + 5}, $${paramOffset + 6}, DEFAULT, DEFAULT, DEFAULT, ${visibleAtClause}, $${paramOffset + 7})`);
175
+ params.push(pm.workflowName, pm.jid, pm.aid, pm.dad, pm.msgType, pm.topic, pm.message, pm.retryAttempt);
176
+ }
177
+ });
178
+ }
125
179
  }
126
180
  return {
127
181
  sql: `INSERT INTO ${tableName} ${insertColumns}
128
- VALUES ${valuesClauses.join(', ')}
182
+ VALUES ${valuesClauses.join(', ')}
129
183
  RETURNING id`,
130
184
  params,
131
185
  };
@@ -134,38 +188,39 @@ exports.buildPublishSQL = buildPublishSQL;
134
188
  /**
135
189
  * Fetch messages from the stream with optional exponential backoff.
136
190
  * Uses SKIP LOCKED for high-concurrency consumption.
191
+ * No group_name filter needed - the table itself determines engine vs worker.
137
192
  */
138
- async function fetchMessages(client, tableName, streamName, groupName, consumerName, options = {}, logger) {
193
+ async function fetchMessages(client, tableName, streamName, isEngine, consumerName, options = {}, logger) {
139
194
  const enableBackoff = options?.enableBackoff ?? false;
140
- const initialBackoff = options?.initialBackoff ?? 100; // Default initial backoff: 100ms
141
- const maxBackoff = options?.maxBackoff ?? 3000; // Default max backoff: 3 seconds
142
- const maxRetries = options?.maxRetries ?? 3; // Set a finite default, e.g., 3 retries
195
+ const initialBackoff = options?.initialBackoff ?? 100;
196
+ const maxBackoff = options?.maxBackoff ?? 3000;
197
+ const maxRetries = options?.maxRetries ?? 3;
143
198
  let backoff = initialBackoff;
144
199
  let retries = 0;
200
+ // Include workflow_name in RETURNING for worker streams
201
+ const returningClause = isEngine
202
+ ? 'id, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt'
203
+ : 'id, message, workflow_name, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt';
145
204
  try {
146
205
  while (retries < maxRetries) {
147
206
  retries++;
148
207
  const batchSize = options?.batchSize || 1;
149
208
  const reservationTimeout = options?.reservationTimeout || 30;
150
- // Simplified query for better performance - especially for notification-triggered fetches
151
- const res = await client.query(`UPDATE ${tableName}
152
- SET reserved_at = NOW(), reserved_by = $4
209
+ const res = await client.query(`UPDATE ${tableName}
210
+ SET reserved_at = NOW(), reserved_by = $3
153
211
  WHERE id IN (
154
212
  SELECT id FROM ${tableName}
155
- WHERE stream_name = $1
156
- AND group_name = $2
213
+ WHERE stream_name = $1
157
214
  AND (reserved_at IS NULL OR reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds')
158
215
  AND expired_at IS NULL
159
216
  AND visible_at <= NOW()
160
217
  ORDER BY id
161
- LIMIT $3
218
+ LIMIT $2
162
219
  FOR UPDATE SKIP LOCKED
163
220
  )
164
- RETURNING id, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt`, [streamName, groupName, batchSize, consumerName]);
221
+ RETURNING ${returningClause}`, [streamName, batchSize, consumerName]);
165
222
  const messages = res.rows.map((row) => {
166
223
  const data = (0, utils_1.parseStreamMessage)(row.message);
167
- // Inject retry policy only if not using default values
168
- // Default values indicate old retry mechanism should be used (policies.retry)
169
224
  const hasDefaultRetryPolicy = (row.max_retry_attempts === 3 || row.max_retry_attempts === 5) &&
170
225
  parseFloat(row.backoff_coefficient) === 10 &&
171
226
  row.maximum_interval_seconds === 120;
@@ -176,10 +231,15 @@ async function fetchMessages(client, tableName, streamName, groupName, consumerN
176
231
  maximum_interval_seconds: row.maximum_interval_seconds,
177
232
  };
178
233
  }
179
- // Inject retry_attempt from database
180
234
  if (row.retry_attempt !== undefined && row.retry_attempt !== null) {
181
235
  data._retryAttempt = row.retry_attempt;
182
236
  }
237
+ // Inject workflow_name from DB column into metadata for dispatch routing
238
+ if (!isEngine && row.workflow_name) {
239
+ if (data.metadata) {
240
+ data.metadata.wfn = row.workflow_name;
241
+ }
242
+ }
183
243
  return {
184
244
  id: row.id.toString(),
185
245
  data,
@@ -193,11 +253,9 @@ async function fetchMessages(client, tableName, streamName, groupName, consumerN
193
253
  if (messages.length > 0 || !enableBackoff) {
194
254
  return messages;
195
255
  }
196
- // Apply backoff if enabled and no messages found
197
256
  await (0, utils_1.sleepFor)(backoff);
198
- backoff = Math.min(backoff * 2, maxBackoff); // Exponential backoff
257
+ backoff = Math.min(backoff * 2, maxBackoff);
199
258
  }
200
- // Return empty array if maxRetries is reached and still no messages
201
259
  return [];
202
260
  }
203
261
  catch (error) {
@@ -212,20 +270,19 @@ exports.fetchMessages = fetchMessages;
212
270
  * Acknowledge messages (no-op for PostgreSQL - uses soft delete pattern).
213
271
  */
214
272
  async function acknowledgeMessages(messageIds) {
215
- // No-op for this implementation
216
273
  return messageIds.length;
217
274
  }
218
275
  exports.acknowledgeMessages = acknowledgeMessages;
219
276
  /**
220
277
  * Delete messages by soft-deleting them (setting expired_at).
278
+ * No group_name needed - stream_name + table is sufficient.
221
279
  */
222
- async function deleteMessages(client, tableName, streamName, groupName, messageIds, logger) {
280
+ async function deleteMessages(client, tableName, streamName, messageIds, logger) {
223
281
  try {
224
282
  const ids = messageIds.map((id) => parseInt(id));
225
- // Perform a soft delete by setting `expired_at` to the current timestamp
226
283
  await client.query(`UPDATE ${tableName}
227
284
  SET expired_at = NOW()
228
- WHERE stream_name = $1 AND id = ANY($2::bigint[]) AND group_name = $3`, [streamName, ids, groupName]);
285
+ WHERE stream_name = $1 AND id = ANY($2::bigint[])`, [streamName, ids]);
229
286
  return messageIds.length;
230
287
  }
231
288
  catch (error) {
@@ -239,15 +296,35 @@ exports.deleteMessages = deleteMessages;
239
296
  /**
240
297
  * Acknowledge and delete messages in one operation.
241
298
  */
242
- async function ackAndDelete(client, tableName, streamName, groupName, messageIds, logger) {
243
- return await deleteMessages(client, tableName, streamName, groupName, messageIds, logger);
299
+ async function ackAndDelete(client, tableName, streamName, messageIds, logger) {
300
+ return await deleteMessages(client, tableName, streamName, messageIds, logger);
244
301
  }
245
302
  exports.ackAndDelete = ackAndDelete;
303
+ /**
304
+ * Move messages to the dead-letter state by setting dead_lettered_at
305
+ * and expired_at. The message payload is preserved for inspection.
306
+ */
307
+ async function deadLetterMessages(client, tableName, streamName, messageIds, logger) {
308
+ try {
309
+ const ids = messageIds.map((id) => parseInt(id));
310
+ const res = await client.query(`UPDATE ${tableName}
311
+ SET dead_lettered_at = NOW(), expired_at = NOW()
312
+ WHERE stream_name = $1 AND id = ANY($2::bigint[])`, [streamName, ids]);
313
+ return res.rowCount;
314
+ }
315
+ catch (error) {
316
+ logger.error(`postgres-stream-dead-letter-error-${streamName}`, {
317
+ error,
318
+ messageIds,
319
+ });
320
+ throw error;
321
+ }
322
+ }
323
+ exports.deadLetterMessages = deadLetterMessages;
246
324
  /**
247
325
  * Retry messages (placeholder for future implementation).
248
326
  */
249
327
  async function retryMessages(streamName, groupName, options) {
250
- // Implement retry logic if needed
251
328
  return [];
252
329
  }
253
330
  exports.retryMessages = retryMessages;
@@ -5,6 +5,9 @@ import { ProviderClient } from '../../../../types/provider';
5
5
  /**
6
6
  * Manages PostgreSQL LISTEN/NOTIFY for stream message notifications.
7
7
  * Handles static state shared across all service instances using the same client.
8
+ *
9
+ * Channel naming uses table-type prefixes (eng_ / wrk_) instead of group_name,
10
+ * since engine_streams and worker_streams are separate tables.
8
11
  */
9
12
  export declare class NotificationManager<TService> {
10
13
  private client;
@@ -27,15 +30,16 @@ export declare class NotificationManager<TService> {
27
30
  startClientFallbackPoller(checkForMissedMessages: () => Promise<void>): void;
28
31
  /**
29
32
  * Check for missed messages (fallback polling).
30
- * Handles errors gracefully to avoid noise during shutdown.
31
33
  */
32
34
  checkForMissedMessages(fetchMessages: (instance: TService, consumer: NotificationConsumer) => Promise<StreamMessage[]>): Promise<void>;
33
35
  /**
34
36
  * Handle incoming PostgreSQL notification.
37
+ * Channels use table-type prefixes: eng_ for engine, wrk_ for worker.
35
38
  */
36
39
  private handleNotification;
37
40
  /**
38
41
  * Set up notification consumer for a stream/group.
42
+ * Uses table-type channel naming (eng_ / wrk_).
39
43
  */
40
44
  setupNotificationConsumer(serviceInstance: TService, streamName: string, groupName: string, consumerName: string, callback: (messages: StreamMessage[]) => void): Promise<void>;
41
45
  /**
@@ -44,7 +48,6 @@ export declare class NotificationManager<TService> {
44
48
  stopNotificationConsumer(serviceInstance: TService, streamName: string, groupName: string): Promise<void>;
45
49
  /**
46
50
  * Clean up notification consumers for this instance.
47
- * Stops fallback poller FIRST to prevent race conditions during shutdown.
48
51
  */
49
52
  cleanup(serviceInstance: TService): Promise<void>;
50
53
  /**
@@ -6,6 +6,9 @@ const kvtables_1 = require("./kvtables");
6
6
  /**
7
7
  * Manages PostgreSQL LISTEN/NOTIFY for stream message notifications.
8
8
  * Handles static state shared across all service instances using the same client.
9
+ *
10
+ * Channel naming uses table-type prefixes (eng_ / wrk_) instead of group_name,
11
+ * since engine_streams and worker_streams are separate tables.
9
12
  */
10
13
  class NotificationManager {
11
14
  constructor(client, getTableName, getFallbackInterval, logger) {
@@ -24,13 +27,10 @@ class NotificationManager {
24
27
  if (NotificationManager.clientNotificationHandlers.get(this.client)) {
25
28
  return;
26
29
  }
27
- // Initialize notification consumer map for this client
28
30
  if (!NotificationManager.clientNotificationConsumers.has(this.client)) {
29
31
  NotificationManager.clientNotificationConsumers.set(this.client, new Map());
30
32
  }
31
- // Set up the notification handler
32
33
  this.client.on('notification', this.notificationHandlerBound);
33
- // Mark this client as having a notification handler
34
34
  NotificationManager.clientNotificationHandlers.set(this.client, true);
35
35
  }
36
36
  /**
@@ -50,7 +50,6 @@ class NotificationManager {
50
50
  }
51
51
  /**
52
52
  * Check for missed messages (fallback polling).
53
- * Handles errors gracefully to avoid noise during shutdown.
54
53
  */
55
54
  async checkForMissedMessages(fetchMessages) {
56
55
  const now = Date.now();
@@ -67,10 +66,7 @@ class NotificationManager {
67
66
  }
68
67
  }
69
68
  catch (error) {
70
- // Silently ignore errors during shutdown (client closed, etc.)
71
- // Function might not exist in older schemas
72
69
  if (error.message?.includes('Client was closed')) {
73
- // Client is shutting down, silently return
74
70
  return;
75
71
  }
76
72
  this.logger.debug('postgres-stream-visibility-function-unavailable', {
@@ -82,7 +78,6 @@ class NotificationManager {
82
78
  if (!clientNotificationConsumers) {
83
79
  return;
84
80
  }
85
- // Check consumers that haven't been checked recently
86
81
  for (const [consumerKey, instanceMap,] of clientNotificationConsumers.entries()) {
87
82
  for (const [instance, consumer] of instanceMap.entries()) {
88
83
  if (consumer.isListening &&
@@ -100,9 +95,7 @@ class NotificationManager {
100
95
  consumer.lastFallbackCheck = now;
101
96
  }
102
97
  catch (error) {
103
- // Silently ignore errors during shutdown
104
98
  if (error.message?.includes('Client was closed')) {
105
- // Client is shutting down, stop checking this consumer
106
99
  consumer.isListening = false;
107
100
  return;
108
101
  }
@@ -118,11 +111,12 @@ class NotificationManager {
118
111
  }
119
112
  /**
120
113
  * Handle incoming PostgreSQL notification.
114
+ * Channels use table-type prefixes: eng_ for engine, wrk_ for worker.
121
115
  */
122
116
  handleNotification(notification) {
123
117
  try {
124
- // Only handle stream notifications
125
- if (!notification.channel.startsWith('stream_')) {
118
+ // Only handle stream notifications (eng_ or wrk_ prefixed)
119
+ if (!notification.channel.startsWith('eng_') && !notification.channel.startsWith('wrk_')) {
126
120
  this.logger.debug('postgres-stream-ignoring-sub-notification', {
127
121
  channel: notification.channel,
128
122
  payloadPreview: notification.payload.substring(0, 100),
@@ -133,14 +127,16 @@ class NotificationManager {
133
127
  channel: notification.channel,
134
128
  });
135
129
  const payload = JSON.parse(notification.payload);
136
- const { stream_name, group_name } = payload;
137
- if (!stream_name || !group_name) {
130
+ const { stream_name, table_type } = payload;
131
+ if (!stream_name || !table_type) {
138
132
  this.logger.warn('postgres-stream-invalid-notification', {
139
133
  notification,
140
134
  });
141
135
  return;
142
136
  }
143
- const consumerKey = this.getConsumerKey(stream_name, group_name);
137
+ // Derive groupName from table_type for consumer key lookup
138
+ const groupName = table_type === 'engine' ? 'ENGINE' : 'WORKER';
139
+ const consumerKey = this.getConsumerKey(stream_name, groupName);
144
140
  const clientNotificationConsumers = NotificationManager.clientNotificationConsumers.get(this.client);
145
141
  if (!clientNotificationConsumers) {
146
142
  return;
@@ -149,7 +145,6 @@ class NotificationManager {
149
145
  if (!instanceMap) {
150
146
  return;
151
147
  }
152
- // Trigger immediate message fetch for all instances with this consumer
153
148
  for (const [instance, consumer] of instanceMap.entries()) {
154
149
  if (consumer.isListening) {
155
150
  const serviceInstance = instance;
@@ -168,11 +163,21 @@ class NotificationManager {
168
163
  }
169
164
  /**
170
165
  * Set up notification consumer for a stream/group.
166
+ * Uses table-type channel naming (eng_ / wrk_).
171
167
  */
172
168
  async setupNotificationConsumer(serviceInstance, streamName, groupName, consumerName, callback) {
173
169
  const startTime = Date.now();
174
- const consumerKey = this.getConsumerKey(streamName, groupName);
175
- const channelName = (0, kvtables_1.getNotificationChannelName)(streamName, groupName);
170
+ const isEngine = groupName === 'ENGINE';
171
+ // Resolve the stream name to get the simplified form for channel naming and consumer key
172
+ const serviceAny = serviceInstance;
173
+ let resolvedStreamName = streamName;
174
+ if (serviceAny.resolveStreamTarget) {
175
+ const target = serviceAny.resolveStreamTarget(streamName);
176
+ resolvedStreamName = target.streamName;
177
+ }
178
+ // Use resolved stream name for consumer key so it matches notification payloads
179
+ const consumerKey = this.getConsumerKey(resolvedStreamName, groupName);
180
+ const channelName = (0, kvtables_1.getNotificationChannelName)(resolvedStreamName, isEngine);
176
181
  // Get or create notification consumer map for this client
177
182
  let clientNotificationConsumers = NotificationManager.clientNotificationConsumers.get(this.client);
178
183
  if (!clientNotificationConsumers) {
@@ -202,7 +207,7 @@ class NotificationManager {
202
207
  channelName,
203
208
  error,
204
209
  });
205
- throw error; // Propagate error to caller
210
+ throw error;
206
211
  }
207
212
  }
208
213
  // Register consumer for this instance
@@ -215,7 +220,6 @@ class NotificationManager {
215
220
  lastFallbackCheck: Date.now(),
216
221
  };
217
222
  instanceMap.set(serviceInstance, consumer);
218
- // Track this consumer for cleanup
219
223
  this.instanceNotificationConsumers.add(consumerKey);
220
224
  this.logger.debug('postgres-stream-notification-setup-complete', {
221
225
  streamName,
@@ -228,7 +232,14 @@ class NotificationManager {
228
232
  * Stop notification consumer for a stream/group.
229
233
  */
230
234
  async stopNotificationConsumer(serviceInstance, streamName, groupName) {
231
- const consumerKey = this.getConsumerKey(streamName, groupName);
235
+ const isEngine = groupName === 'ENGINE';
236
+ const serviceAny = serviceInstance;
237
+ let resolvedStreamName = streamName;
238
+ if (serviceAny.resolveStreamTarget) {
239
+ const target = serviceAny.resolveStreamTarget(streamName);
240
+ resolvedStreamName = target.streamName;
241
+ }
242
+ const consumerKey = this.getConsumerKey(resolvedStreamName, groupName);
232
243
  const clientNotificationConsumers = NotificationManager.clientNotificationConsumers.get(this.client);
233
244
  if (!clientNotificationConsumers) {
234
245
  return;
@@ -241,12 +252,10 @@ class NotificationManager {
241
252
  if (consumer) {
242
253
  consumer.isListening = false;
243
254
  instanceMap.delete(serviceInstance);
244
- // Remove from instance tracking
245
255
  this.instanceNotificationConsumers.delete(consumerKey);
246
- // If no more instances for this consumer key, stop listening
247
256
  if (instanceMap.size === 0) {
248
257
  clientNotificationConsumers.delete(consumerKey);
249
- const channelName = (0, kvtables_1.getNotificationChannelName)(streamName, groupName);
258
+ const channelName = (0, kvtables_1.getNotificationChannelName)(resolvedStreamName, isEngine);
250
259
  try {
251
260
  await this.client.query(`UNLISTEN "${channelName}"`);
252
261
  this.logger.debug('postgres-stream-unlisten', {
@@ -268,7 +277,6 @@ class NotificationManager {
268
277
  }
269
278
  /**
270
279
  * Clean up notification consumers for this instance.
271
- * Stops fallback poller FIRST to prevent race conditions during shutdown.
272
280
  */
273
281
  async cleanup(serviceInstance) {
274
282
  const clientNotificationConsumers = NotificationManager.clientNotificationConsumers.get(this.client);
@@ -279,7 +287,6 @@ class NotificationManager {
279
287
  NotificationManager.clientFallbackPollers.delete(this.client);
280
288
  }
281
289
  if (clientNotificationConsumers) {
282
- // Remove this instance from all consumer maps
283
290
  for (const consumerKey of this.instanceNotificationConsumers) {
284
291
  const instanceMap = clientNotificationConsumers.get(consumerKey);
285
292
  if (instanceMap) {
@@ -287,10 +294,13 @@ class NotificationManager {
287
294
  if (consumer) {
288
295
  consumer.isListening = false;
289
296
  instanceMap.delete(serviceInstance);
290
- // If no more instances for this consumer, stop listening
291
297
  if (instanceMap.size === 0) {
292
298
  clientNotificationConsumers.delete(consumerKey);
293
- const channelName = (0, kvtables_1.getNotificationChannelName)(consumer.streamName, consumer.groupName);
299
+ // Extract resolved stream name and isEngine from the consumer key
300
+ // Consumer key format: resolvedStreamName:groupName
301
+ const isEngine = consumer.groupName === 'ENGINE';
302
+ const resolvedStreamName = consumerKey.substring(0, consumerKey.lastIndexOf(':'));
303
+ const channelName = (0, kvtables_1.getNotificationChannelName)(resolvedStreamName, isEngine);
294
304
  try {
295
305
  await this.client.query(`UNLISTEN "${channelName}"`);
296
306
  this.logger.debug('postgres-stream-cleanup-unlisten', {
@@ -300,7 +310,6 @@ class NotificationManager {
300
310
  });
301
311
  }
302
312
  catch (error) {
303
- // Silently ignore errors during shutdown
304
313
  if (!error.message?.includes('Client was closed')) {
305
314
  this.logger.error('postgres-stream-cleanup-unlisten-error', {
306
315
  streamName: consumer.streamName,
@@ -315,15 +324,10 @@ class NotificationManager {
315
324
  }
316
325
  }
317
326
  }
318
- // Clear instance tracking
319
327
  this.instanceNotificationConsumers.clear();
320
- // If no more consumers exist for this client, clean up static resources
321
328
  if (clientNotificationConsumers && clientNotificationConsumers.size === 0) {
322
- // Remove client from static maps
323
329
  NotificationManager.clientNotificationConsumers.delete(this.client);
324
330
  NotificationManager.clientNotificationHandlers.delete(this.client);
325
- // Fallback poller already stopped above
326
- // Remove notification handler
327
331
  if (this.client.removeAllListeners) {
328
332
  this.client.removeAllListeners('notification');
329
333
  }
@@ -352,6 +356,6 @@ function getFallbackInterval(config) {
352
356
  }
353
357
  exports.getFallbackInterval = getFallbackInterval;
354
358
  function getNotificationTimeout(config) {
355
- return config?.postgres?.notificationTimeout || 5000; // Default: 5 seconds
359
+ return config?.postgres?.notificationTimeout || 5000;
356
360
  }
357
361
  exports.getNotificationTimeout = getNotificationTimeout;