@hotmeshio/hotmesh 0.10.2 → 0.11.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 (45) hide show
  1. package/README.md +1 -1
  2. package/build/modules/errors.d.ts +2 -0
  3. package/build/modules/errors.js +2 -0
  4. package/build/modules/key.js +3 -2
  5. package/build/package.json +2 -2
  6. package/build/services/activities/worker.js +10 -0
  7. package/build/services/dba/index.d.ts +2 -1
  8. package/build/services/dba/index.js +11 -2
  9. package/build/services/durable/client.js +6 -1
  10. package/build/services/durable/exporter.d.ts +15 -0
  11. package/build/services/durable/exporter.js +343 -5
  12. package/build/services/durable/schemas/factory.d.ts +1 -1
  13. package/build/services/durable/schemas/factory.js +27 -4
  14. package/build/services/durable/worker.d.ts +2 -2
  15. package/build/services/durable/worker.js +15 -9
  16. package/build/services/durable/workflow/context.js +2 -0
  17. package/build/services/durable/workflow/execChild.js +5 -2
  18. package/build/services/durable/workflow/hook.js +6 -0
  19. package/build/services/durable/workflow/proxyActivities.js +3 -4
  20. package/build/services/engine/index.js +5 -3
  21. package/build/services/store/index.d.ts +40 -0
  22. package/build/services/store/providers/postgres/exporter-sql.d.ts +23 -0
  23. package/build/services/store/providers/postgres/exporter-sql.js +52 -0
  24. package/build/services/store/providers/postgres/kvtables.js +6 -0
  25. package/build/services/store/providers/postgres/postgres.d.ts +34 -0
  26. package/build/services/store/providers/postgres/postgres.js +99 -0
  27. package/build/services/stream/providers/postgres/kvtables.d.ts +1 -1
  28. package/build/services/stream/providers/postgres/kvtables.js +175 -82
  29. package/build/services/stream/providers/postgres/lifecycle.d.ts +4 -3
  30. package/build/services/stream/providers/postgres/lifecycle.js +6 -5
  31. package/build/services/stream/providers/postgres/messages.d.ts +9 -6
  32. package/build/services/stream/providers/postgres/messages.js +121 -75
  33. package/build/services/stream/providers/postgres/notifications.d.ts +5 -2
  34. package/build/services/stream/providers/postgres/notifications.js +39 -35
  35. package/build/services/stream/providers/postgres/postgres.d.ts +20 -118
  36. package/build/services/stream/providers/postgres/postgres.js +83 -140
  37. package/build/services/stream/registry.d.ts +62 -0
  38. package/build/services/stream/registry.js +198 -0
  39. package/build/services/worker/index.js +20 -6
  40. package/build/types/durable.d.ts +6 -1
  41. package/build/types/error.d.ts +2 -0
  42. package/build/types/exporter.d.ts +39 -0
  43. package/build/types/hotmesh.d.ts +7 -1
  44. package/build/types/stream.d.ts +2 -0
  45. package/package.json +2 -2
@@ -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,8 @@ function buildPublishSQL(tableName, streamName, messages, options) {
50
49
  delete data._streamRetryConfig;
51
50
  delete data._visibilityDelayMs;
52
51
  delete data._retryAttempt;
52
+ // Extract workflow name for worker streams
53
+ const workflowName = data.metadata?.wfn || '';
53
54
  // Determine if this message has explicit retry config
54
55
  const hasExplicitConfig = (retryConfig && 'max_retry_attempts' in retryConfig) || options?.retryPolicy;
55
56
  let normalizedPolicy = null;
@@ -69,63 +70,106 @@ function buildPublishSQL(tableName, streamName, messages, options) {
69
70
  retryPolicy: normalizedPolicy,
70
71
  visibilityDelayMs: visibilityDelayMs || 0,
71
72
  retryAttempt: retryAttempt || 0,
73
+ workflowName,
72
74
  };
73
75
  });
74
- const params = [streamName, groupName];
76
+ const params = [streamName];
75
77
  let valuesClauses = [];
76
78
  let insertColumns;
77
- // Check if ALL messages have explicit config or ALL don't
78
79
  const allHaveConfig = parsedMessages.every(pm => pm.hasExplicitConfig);
79
80
  const noneHaveConfig = parsedMessages.every(pm => !pm.hasExplicitConfig);
80
81
  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
- });
82
+ if (isEngine) {
83
+ // Engine table: no group_name, no workflow_name
84
+ if (noneHaveConfig && !hasVisibilityDelays) {
85
+ insertColumns = '(stream_name, message)';
86
+ parsedMessages.forEach((pm, idx) => {
87
+ const base = idx * 1;
88
+ valuesClauses.push(`($1, $${base + 2})`);
89
+ params.push(pm.message);
90
+ });
91
+ }
92
+ else if (noneHaveConfig && hasVisibilityDelays) {
93
+ insertColumns = '(stream_name, message, visible_at, retry_attempt)';
94
+ parsedMessages.forEach((pm, idx) => {
95
+ const base = idx * 2;
96
+ if (pm.visibilityDelayMs > 0) {
97
+ const visibleAtSQL = `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`;
98
+ valuesClauses.push(`($1, $${base + 2}, ${visibleAtSQL}, $${base + 3})`);
99
+ params.push(pm.message, pm.retryAttempt);
100
+ }
101
+ else {
102
+ valuesClauses.push(`($1, $${base + 2}, DEFAULT, $${base + 3})`);
103
+ params.push(pm.message, pm.retryAttempt);
104
+ }
105
+ });
106
+ }
107
+ else {
108
+ insertColumns = '(stream_name, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)';
109
+ parsedMessages.forEach((pm) => {
110
+ const visibleAtClause = pm.visibilityDelayMs > 0
111
+ ? `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`
112
+ : 'DEFAULT';
113
+ if (pm.hasExplicitConfig) {
114
+ const paramOffset = params.length + 1;
115
+ valuesClauses.push(`($1, $${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
+ const paramOffset = params.length + 1;
120
+ valuesClauses.push(`($1, $${paramOffset}, DEFAULT, DEFAULT, DEFAULT, ${visibleAtClause}, $${paramOffset + 1})`);
121
+ params.push(pm.message, pm.retryAttempt);
122
+ }
123
+ });
124
+ }
105
125
  }
106
126
  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
127
+ // Worker table: includes workflow_name, no group_name
128
+ if (noneHaveConfig && !hasVisibilityDelays) {
129
+ insertColumns = '(stream_name, workflow_name, message)';
130
+ parsedMessages.forEach((pm) => {
120
131
  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
- });
132
+ valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1})`);
133
+ params.push(pm.workflowName, pm.message);
134
+ });
135
+ }
136
+ else if (noneHaveConfig && hasVisibilityDelays) {
137
+ insertColumns = '(stream_name, workflow_name, message, visible_at, retry_attempt)';
138
+ parsedMessages.forEach((pm) => {
139
+ const paramOffset = params.length + 1;
140
+ if (pm.visibilityDelayMs > 0) {
141
+ const visibleAtSQL = `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`;
142
+ valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, ${visibleAtSQL}, $${paramOffset + 2})`);
143
+ params.push(pm.workflowName, pm.message, pm.retryAttempt);
144
+ }
145
+ else {
146
+ valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, DEFAULT, $${paramOffset + 2})`);
147
+ params.push(pm.workflowName, pm.message, pm.retryAttempt);
148
+ }
149
+ });
150
+ }
151
+ else {
152
+ insertColumns = '(stream_name, workflow_name, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)';
153
+ parsedMessages.forEach((pm) => {
154
+ const visibleAtClause = pm.visibilityDelayMs > 0
155
+ ? `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`
156
+ : 'DEFAULT';
157
+ if (pm.hasExplicitConfig) {
158
+ const paramOffset = params.length + 1;
159
+ valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, ${visibleAtClause}, $${paramOffset + 5})`);
160
+ params.push(pm.workflowName, pm.message, pm.retryPolicy.max_retry_attempts, pm.retryPolicy.backoff_coefficient, pm.retryPolicy.maximum_interval_seconds, pm.retryAttempt);
161
+ }
162
+ else {
163
+ const paramOffset = params.length + 1;
164
+ valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, DEFAULT, DEFAULT, DEFAULT, ${visibleAtClause}, $${paramOffset + 2})`);
165
+ params.push(pm.workflowName, pm.message, pm.retryAttempt);
166
+ }
167
+ });
168
+ }
125
169
  }
126
170
  return {
127
171
  sql: `INSERT INTO ${tableName} ${insertColumns}
128
- VALUES ${valuesClauses.join(', ')}
172
+ VALUES ${valuesClauses.join(', ')}
129
173
  RETURNING id`,
130
174
  params,
131
175
  };
@@ -134,38 +178,39 @@ exports.buildPublishSQL = buildPublishSQL;
134
178
  /**
135
179
  * Fetch messages from the stream with optional exponential backoff.
136
180
  * Uses SKIP LOCKED for high-concurrency consumption.
181
+ * No group_name filter needed - the table itself determines engine vs worker.
137
182
  */
138
- async function fetchMessages(client, tableName, streamName, groupName, consumerName, options = {}, logger) {
183
+ async function fetchMessages(client, tableName, streamName, isEngine, consumerName, options = {}, logger) {
139
184
  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
185
+ const initialBackoff = options?.initialBackoff ?? 100;
186
+ const maxBackoff = options?.maxBackoff ?? 3000;
187
+ const maxRetries = options?.maxRetries ?? 3;
143
188
  let backoff = initialBackoff;
144
189
  let retries = 0;
190
+ // Include workflow_name in RETURNING for worker streams
191
+ const returningClause = isEngine
192
+ ? 'id, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt'
193
+ : 'id, message, workflow_name, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt';
145
194
  try {
146
195
  while (retries < maxRetries) {
147
196
  retries++;
148
197
  const batchSize = options?.batchSize || 1;
149
198
  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
199
+ const res = await client.query(`UPDATE ${tableName}
200
+ SET reserved_at = NOW(), reserved_by = $3
153
201
  WHERE id IN (
154
202
  SELECT id FROM ${tableName}
155
- WHERE stream_name = $1
156
- AND group_name = $2
203
+ WHERE stream_name = $1
157
204
  AND (reserved_at IS NULL OR reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds')
158
205
  AND expired_at IS NULL
159
206
  AND visible_at <= NOW()
160
207
  ORDER BY id
161
- LIMIT $3
208
+ LIMIT $2
162
209
  FOR UPDATE SKIP LOCKED
163
210
  )
164
- RETURNING id, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt`, [streamName, groupName, batchSize, consumerName]);
211
+ RETURNING ${returningClause}`, [streamName, batchSize, consumerName]);
165
212
  const messages = res.rows.map((row) => {
166
213
  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
214
  const hasDefaultRetryPolicy = (row.max_retry_attempts === 3 || row.max_retry_attempts === 5) &&
170
215
  parseFloat(row.backoff_coefficient) === 10 &&
171
216
  row.maximum_interval_seconds === 120;
@@ -176,10 +221,15 @@ async function fetchMessages(client, tableName, streamName, groupName, consumerN
176
221
  maximum_interval_seconds: row.maximum_interval_seconds,
177
222
  };
178
223
  }
179
- // Inject retry_attempt from database
180
224
  if (row.retry_attempt !== undefined && row.retry_attempt !== null) {
181
225
  data._retryAttempt = row.retry_attempt;
182
226
  }
227
+ // Inject workflow_name from DB column into metadata for dispatch routing
228
+ if (!isEngine && row.workflow_name) {
229
+ if (data.metadata) {
230
+ data.metadata.wfn = row.workflow_name;
231
+ }
232
+ }
183
233
  return {
184
234
  id: row.id.toString(),
185
235
  data,
@@ -193,11 +243,9 @@ async function fetchMessages(client, tableName, streamName, groupName, consumerN
193
243
  if (messages.length > 0 || !enableBackoff) {
194
244
  return messages;
195
245
  }
196
- // Apply backoff if enabled and no messages found
197
246
  await (0, utils_1.sleepFor)(backoff);
198
- backoff = Math.min(backoff * 2, maxBackoff); // Exponential backoff
247
+ backoff = Math.min(backoff * 2, maxBackoff);
199
248
  }
200
- // Return empty array if maxRetries is reached and still no messages
201
249
  return [];
202
250
  }
203
251
  catch (error) {
@@ -212,20 +260,19 @@ exports.fetchMessages = fetchMessages;
212
260
  * Acknowledge messages (no-op for PostgreSQL - uses soft delete pattern).
213
261
  */
214
262
  async function acknowledgeMessages(messageIds) {
215
- // No-op for this implementation
216
263
  return messageIds.length;
217
264
  }
218
265
  exports.acknowledgeMessages = acknowledgeMessages;
219
266
  /**
220
267
  * Delete messages by soft-deleting them (setting expired_at).
268
+ * No group_name needed - stream_name + table is sufficient.
221
269
  */
222
- async function deleteMessages(client, tableName, streamName, groupName, messageIds, logger) {
270
+ async function deleteMessages(client, tableName, streamName, messageIds, logger) {
223
271
  try {
224
272
  const ids = messageIds.map((id) => parseInt(id));
225
- // Perform a soft delete by setting `expired_at` to the current timestamp
226
273
  await client.query(`UPDATE ${tableName}
227
274
  SET expired_at = NOW()
228
- WHERE stream_name = $1 AND id = ANY($2::bigint[]) AND group_name = $3`, [streamName, ids, groupName]);
275
+ WHERE stream_name = $1 AND id = ANY($2::bigint[])`, [streamName, ids]);
229
276
  return messageIds.length;
230
277
  }
231
278
  catch (error) {
@@ -239,15 +286,14 @@ exports.deleteMessages = deleteMessages;
239
286
  /**
240
287
  * Acknowledge and delete messages in one operation.
241
288
  */
242
- async function ackAndDelete(client, tableName, streamName, groupName, messageIds, logger) {
243
- return await deleteMessages(client, tableName, streamName, groupName, messageIds, logger);
289
+ async function ackAndDelete(client, tableName, streamName, messageIds, logger) {
290
+ return await deleteMessages(client, tableName, streamName, messageIds, logger);
244
291
  }
245
292
  exports.ackAndDelete = ackAndDelete;
246
293
  /**
247
294
  * Retry messages (placeholder for future implementation).
248
295
  */
249
296
  async function retryMessages(streamName, groupName, options) {
250
- // Implement retry logic if needed
251
297
  return [];
252
298
  }
253
299
  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;