@ceschiatti/redistail 0.0.1 → 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.
- package/dist/cli/redistail.exe +0 -0
- package/dist/cli/redistail.js +1 -1
- package/package.json +3 -2
- package/src/cli/config-service.ts +455 -0
- package/src/cli/display-service.ts +321 -0
- package/src/cli/layers.ts +492 -0
- package/src/cli/pubsub-monitor-service.ts +296 -0
- package/src/cli/redistail-launcher.js +65 -0
- package/src/cli/redistail.ts +417 -0
- package/src/cli/signal-handler-service.ts +210 -0
- package/src/cli/stream-monitor-service.ts +427 -0
- package/src/cli/types.ts +302 -0
- package/src/cli/version-generated.ts +27 -0
- package/src/cli/version.ts +27 -0
|
@@ -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
|
+
};
|