@ceschiatti/redistail 0.0.2 → 0.0.3

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.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Stream monitoring service for the redistail CLI utility.
3
+ *
4
+ * This service provides Redis Stream monitoring capabilities with
5
+ * automatic reconnection, entry ID continuity tracking, and proper error handling.
6
+ * It integrates with the effect-redis RedisStream service and follows
7
+ * Effect-TS patterns for dependency injection and resource management.
8
+ */
9
+
10
+ import {
11
+ Context,
12
+ Effect,
13
+ Layer,
14
+ Schedule,
15
+ Stream,
16
+ pipe,
17
+ Duration,
18
+ Option,
19
+ } from 'effect';
20
+ import { RedisStream } from 'effect-redis';
21
+
22
+ import {
23
+ type StreamMessage,
24
+ type RedistailConfig,
25
+ type MonitoringError,
26
+ type ConfigError,
27
+ type StreamMonitorOptions,
28
+ StreamMessage as StreamMessageConstructor,
29
+ createStreamReadFailedError,
30
+ } from './types';
31
+ import { CLIConfigService } from './config-service';
32
+ import type { RedisError, RedisValue } from 'effect-redis';
33
+
34
+ // Get the service type from the Context.Tag
35
+ type RedisStreamService = Context.Tag.Service<RedisStream>;
36
+
37
+ // ============================================================================
38
+ // Service Interface
39
+ // ============================================================================
40
+
41
+ /**
42
+ * Service interface for Stream monitoring operations
43
+ */
44
+ export interface StreamMonitorService {
45
+ /**
46
+ * Monitor a Redis Stream with automatic reconnection and entry ID continuity
47
+ * @param streamKey - The stream key to monitor
48
+ * @param options - Optional monitoring configuration
49
+ * @returns Stream of Stream messages with error handling and reconnection
50
+ */
51
+ readonly monitor: (
52
+ streamKey: string,
53
+ options?: StreamMonitorOptions,
54
+ ) => Stream.Stream<StreamMessage, MonitoringError>;
55
+
56
+ /**
57
+ * Subscribe to a Redis Stream starting from the latest entry
58
+ * @param streamKey - The stream key to subscribe to
59
+ * @returns Effect that yields a stream of Stream messages
60
+ */
61
+ readonly subscribeFromLatest: (
62
+ streamKey: string,
63
+ ) => Effect.Effect<
64
+ Stream.Stream<StreamMessage, MonitoringError>,
65
+ MonitoringError
66
+ >;
67
+
68
+ /**
69
+ * Subscribe to a Redis Stream with custom options
70
+ * @param streamKey - The stream key to subscribe to
71
+ * @param options - Subscription options including start ID and block timeout
72
+ * @returns Effect that yields a stream of Stream messages
73
+ */
74
+ readonly subscribeWithOptions: (
75
+ streamKey: string,
76
+ options: StreamMonitorOptions,
77
+ ) => Effect.Effect<
78
+ Stream.Stream<StreamMessage, MonitoringError>,
79
+ MonitoringError
80
+ >;
81
+ }
82
+
83
+ /**
84
+ * Context tag for Stream monitoring service dependency injection
85
+ */
86
+ export const StreamMonitorService = Context.GenericTag<StreamMonitorService>(
87
+ 'StreamMonitorService',
88
+ );
89
+
90
+ // ============================================================================
91
+ // Helper Functions
92
+ // ============================================================================
93
+
94
+ /**
95
+ * Convert RedisError to MonitoringError
96
+ */
97
+ const convertRedisError = (
98
+ error: RedisError,
99
+ context: string,
100
+ ): MonitoringError => {
101
+ return createStreamReadFailedError(
102
+ `${context}: ${error.message}`,
103
+ true,
104
+ error,
105
+ );
106
+ };
107
+
108
+ /**
109
+ * Transform Redis StreamEntry to StreamMessage
110
+ */
111
+ const transformStreamEntry = (
112
+ streamKey: string,
113
+ entry: { id: RedisValue; message: Record<string, string> },
114
+ ): StreamMessage =>
115
+ StreamMessageConstructor({
116
+ timestamp: new Date(),
117
+ streamKey,
118
+ entryId: String(entry.id),
119
+ fields: entry.message,
120
+ });
121
+
122
+ /**
123
+ * Create a stream subscription with reconnection and continuity tracking
124
+ */
125
+ const createStreamSubscription = (
126
+ redisStreamService: RedisStreamService,
127
+ streamKey: string,
128
+ config: RedistailConfig,
129
+ options?: StreamMonitorOptions,
130
+ ): Stream.Stream<StreamMessage, MonitoringError> => {
131
+ const startId = options?.startId ?? '$'; // '$' means start from latest
132
+ const blockTimeout = options?.blockTimeout ?? config.monitoring.blockTimeout;
133
+ const count = options?.count;
134
+
135
+ return pipe(
136
+ // Create the base subscription stream
137
+ redisStreamService.subscribe(streamKey, {
138
+ id: startId,
139
+ block: blockTimeout,
140
+ count,
141
+ }),
142
+ // Handle Redis errors and convert to MonitoringError
143
+ Stream.mapError((error: RedisError) =>
144
+ convertRedisError(error, `Failed to read from stream '${streamKey}'`),
145
+ ),
146
+ // Transform Redis entries to StreamMessage format
147
+ Stream.map((entry) => transformStreamEntry(streamKey, entry)),
148
+ // Add automatic reconnection with exponential backoff
149
+ Stream.retry(
150
+ pipe(
151
+ Schedule.exponential(Duration.seconds(1)),
152
+ Schedule.intersect(
153
+ Schedule.recurs(config.monitoring.maxReconnectAttempts),
154
+ ),
155
+ Schedule.tapInput((error: MonitoringError) =>
156
+ Effect.logWarning(
157
+ `Stream monitoring error for '${streamKey}': ${error.message}. Retrying...`,
158
+ ),
159
+ ),
160
+ ),
161
+ ),
162
+ // Handle final failure after max retries
163
+ Stream.catchAll((error: MonitoringError) =>
164
+ Stream.fail(
165
+ createStreamReadFailedError(
166
+ `Failed to monitor stream '${streamKey}' after ${config.monitoring.maxReconnectAttempts} attempts: ${error.message}`,
167
+ false, // Not retryable after max attempts
168
+ error,
169
+ ),
170
+ ),
171
+ ),
172
+ );
173
+ };
174
+
175
+ // ============================================================================
176
+ // Service Implementation
177
+ // ============================================================================
178
+
179
+ /**
180
+ * Creates a Stream monitor service implementation
181
+ */
182
+ const createStreamMonitorService = (
183
+ redisStreamService: RedisStreamService,
184
+ config: RedistailConfig,
185
+ ): StreamMonitorService => ({
186
+ monitor: (streamKey: string, options?: StreamMonitorOptions) =>
187
+ createStreamSubscription(redisStreamService, streamKey, config, options),
188
+
189
+ subscribeFromLatest: (streamKey: string) =>
190
+ Effect.gen(function* () {
191
+ // Subscribe starting from the latest entry using '$' ID
192
+ const messageStream = pipe(
193
+ redisStreamService.subscribe(streamKey, {
194
+ id: '$', // Start from latest entries only
195
+ block: config.monitoring.blockTimeout,
196
+ }),
197
+ Stream.mapError((error: RedisError) =>
198
+ convertRedisError(
199
+ error,
200
+ `Failed to subscribe to stream '${streamKey}'`,
201
+ ),
202
+ ),
203
+ Stream.map((entry) => transformStreamEntry(streamKey, entry)),
204
+ );
205
+
206
+ return messageStream;
207
+ }),
208
+
209
+ subscribeWithOptions: (streamKey: string, options: StreamMonitorOptions) =>
210
+ Effect.gen(function* () {
211
+ // Subscribe with custom options
212
+ const subscribeOptions = {
213
+ id: options.startId ?? '$',
214
+ block: options.blockTimeout ?? config.monitoring.blockTimeout,
215
+ count: options.count,
216
+ };
217
+
218
+ const messageStream = pipe(
219
+ redisStreamService.subscribe(streamKey, subscribeOptions),
220
+ Stream.mapError((error: RedisError) =>
221
+ convertRedisError(
222
+ error,
223
+ `Failed to subscribe to stream '${streamKey}' with options`,
224
+ ),
225
+ ),
226
+ Stream.map((entry) => transformStreamEntry(streamKey, entry)),
227
+ );
228
+
229
+ return messageStream;
230
+ }),
231
+ });
232
+
233
+ // ============================================================================
234
+ // Live Layer Implementation
235
+ // ============================================================================
236
+
237
+ /**
238
+ * Live layer for Stream monitoring service
239
+ * Depends on RedisStream and CLIConfigService services
240
+ */
241
+ export const StreamMonitorServiceLive: Layer.Layer<
242
+ StreamMonitorService,
243
+ ConfigError,
244
+ RedisStream | CLIConfigService
245
+ > = Layer.effect(
246
+ StreamMonitorService,
247
+ Effect.gen(function* () {
248
+ const redisStreamService = yield* RedisStream;
249
+ const configService = yield* CLIConfigService;
250
+ const config = yield* configService.loadConfig();
251
+ return createStreamMonitorService(redisStreamService, config);
252
+ }),
253
+ );
254
+
255
+ // ============================================================================
256
+ // Utility Functions
257
+ // ============================================================================
258
+
259
+ /**
260
+ * Helper function to create a monitoring stream with custom retry configuration
261
+ */
262
+ export const createMonitoringStreamWithRetry = (
263
+ streamKey: string,
264
+ redisStreamService: RedisStreamService,
265
+ maxRetries: number,
266
+ baseDelaySeconds = 1,
267
+ startId = '$',
268
+ ): Stream.Stream<StreamMessage, MonitoringError> =>
269
+ pipe(
270
+ redisStreamService.subscribe(streamKey, {
271
+ id: startId,
272
+ block: 5000, // 5 second block timeout
273
+ }),
274
+ Stream.mapError((error: RedisError) =>
275
+ convertRedisError(error, `Failed to subscribe to stream '${streamKey}'`),
276
+ ),
277
+ Stream.map((entry) => transformStreamEntry(streamKey, entry)),
278
+ Stream.retry(
279
+ pipe(
280
+ Schedule.exponential(Duration.seconds(baseDelaySeconds)),
281
+ Schedule.intersect(Schedule.recurs(maxRetries)),
282
+ Schedule.tapInput((error: MonitoringError) =>
283
+ Effect.logWarning(
284
+ `Stream monitoring error for '${streamKey}': ${error.message}. Retrying...`,
285
+ ),
286
+ ),
287
+ ),
288
+ ),
289
+ );
290
+
291
+ /**
292
+ * Helper function to validate stream key names
293
+ */
294
+ export const validateStreamKey = (
295
+ streamKey: string,
296
+ ): Effect.Effect<string, MonitoringError> =>
297
+ Effect.gen(function* () {
298
+ if (!streamKey || typeof streamKey !== 'string') {
299
+ return yield* Effect.fail(
300
+ createStreamReadFailedError(
301
+ 'Stream key must be a non-empty string',
302
+ false,
303
+ ),
304
+ );
305
+ }
306
+
307
+ const trimmedStreamKey = streamKey.trim();
308
+ if (trimmedStreamKey.length === 0) {
309
+ return yield* Effect.fail(
310
+ createStreamReadFailedError(
311
+ 'Stream key cannot be empty or whitespace only',
312
+ false,
313
+ ),
314
+ );
315
+ }
316
+
317
+ return trimmedStreamKey;
318
+ });
319
+
320
+ /**
321
+ * Helper function to create a monitored stream with validation
322
+ */
323
+ export const monitorStreamWithValidation = (
324
+ streamKey: string,
325
+ streamService: StreamMonitorService,
326
+ options?: StreamMonitorOptions,
327
+ ): Stream.Stream<StreamMessage, MonitoringError> =>
328
+ pipe(
329
+ validateStreamKey(streamKey),
330
+ Effect.map((validStreamKey) =>
331
+ streamService.monitor(validStreamKey, options),
332
+ ),
333
+ Stream.unwrap,
334
+ );
335
+
336
+ /**
337
+ * Helper function to create a stream subscription with entry ID continuity tracking
338
+ * This ensures that after reconnection, we resume from the last seen entry ID
339
+ */
340
+ export const createStreamWithContinuity = (
341
+ streamKey: string,
342
+ redisStreamService: RedisStreamService,
343
+ config: RedistailConfig,
344
+ initialStartId = '$',
345
+ ): Stream.Stream<StreamMessage, MonitoringError> => {
346
+ // Use a ref to track the last seen entry ID for continuity
347
+ return pipe(
348
+ Effect.sync(() => ({ lastEntryId: initialStartId })),
349
+ Effect.map((state) =>
350
+ pipe(
351
+ redisStreamService.subscribe(streamKey, {
352
+ id: state.lastEntryId,
353
+ block: config.monitoring.blockTimeout,
354
+ }),
355
+ Stream.mapError((error: RedisError) =>
356
+ convertRedisError(error, `Failed to read from stream '${streamKey}'`),
357
+ ),
358
+ Stream.map((entry) => {
359
+ // Update the last seen entry ID for continuity
360
+ state.lastEntryId = String(entry.id);
361
+ return transformStreamEntry(streamKey, entry);
362
+ }),
363
+ // Add reconnection with continuity - use the updated lastEntryId
364
+ Stream.retry(
365
+ pipe(
366
+ Schedule.exponential(Duration.seconds(1)),
367
+ Schedule.intersect(
368
+ Schedule.recurs(config.monitoring.maxReconnectAttempts),
369
+ ),
370
+ Schedule.tapInput((error: MonitoringError) =>
371
+ Effect.logInfo(
372
+ `Stream reconnecting for '${streamKey}' from entry ID '${state.lastEntryId}': ${error.message}`,
373
+ ),
374
+ ),
375
+ ),
376
+ ),
377
+ ),
378
+ ),
379
+ Stream.unwrap,
380
+ );
381
+ };
382
+
383
+ /**
384
+ * Helper function to parse stream entry ID for comparison
385
+ * Stream IDs are in format "timestamp-sequence" (e.g., "1609459200000-0")
386
+ */
387
+ export const parseStreamEntryId = (
388
+ entryId: string,
389
+ ): Option.Option<{ timestamp: number; sequence: number }> =>
390
+ pipe(
391
+ Effect.sync(() => {
392
+ const parts = entryId.split('-');
393
+ if (parts.length !== 2) return Option.none();
394
+
395
+ const timestamp = Number.parseInt(parts[0], 10);
396
+ const sequence = Number.parseInt(parts[1], 10);
397
+
398
+ if (Number.isNaN(timestamp) || Number.isNaN(sequence))
399
+ return Option.none();
400
+
401
+ return Option.some({ timestamp, sequence });
402
+ }),
403
+ Effect.runSync,
404
+ );
405
+
406
+ /**
407
+ * Helper function to compare stream entry IDs
408
+ * Returns -1 if id1 < id2, 0 if equal, 1 if id1 > id2
409
+ */
410
+ export const compareStreamEntryIds = (id1: string, id2: string): number => {
411
+ const parsed1 = parseStreamEntryId(id1);
412
+ const parsed2 = parseStreamEntryId(id2);
413
+
414
+ if (Option.isNone(parsed1) || Option.isNone(parsed2)) {
415
+ // Fallback to string comparison if parsing fails
416
+ return id1.localeCompare(id2);
417
+ }
418
+
419
+ const { timestamp: ts1, sequence: seq1 } = parsed1.value;
420
+ const { timestamp: ts2, sequence: seq2 } = parsed2.value;
421
+
422
+ if (ts1 !== ts2) {
423
+ return ts1 - ts2;
424
+ }
425
+
426
+ return seq1 - seq2;
427
+ };