@ceschiatti/redistail 0.0.2 → 0.0.4

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,296 @@
1
+ /**
2
+ * PubSub monitoring service for the redistail CLI utility.
3
+ *
4
+ * This service provides Redis Pub/Sub channel monitoring capabilities with
5
+ * automatic reconnection, exponential backoff, and proper error handling.
6
+ * It integrates with the effect-redis RedisPubSub 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
+ } from 'effect';
19
+ import { RedisPubSub } from 'effect-redis';
20
+ import {
21
+ type PubSubMessage,
22
+ type RedistailConfig,
23
+ type MonitoringError,
24
+ type ConfigError,
25
+ PubSubMessage as PubSubMessageConstructor,
26
+ createConnectionLostError,
27
+ createSubscriptionFailedError,
28
+ } from './types';
29
+ import { CLIConfigService } from './config-service';
30
+ import type { RedisError } from 'effect-redis';
31
+
32
+ // Get the service type from the Context.Tag
33
+ type RedisPubSubService = Context.Tag.Service<RedisPubSub>;
34
+
35
+ // ============================================================================
36
+ // Service Interface
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Service interface for PubSub monitoring operations
41
+ */
42
+ export interface PubSubMonitorService {
43
+ /**
44
+ * Monitor a Redis Pub/Sub channel with automatic reconnection
45
+ * @param channel - The channel name to monitor
46
+ * @returns Stream of PubSub messages with error handling and reconnection
47
+ */
48
+ readonly monitor: (
49
+ channel: string,
50
+ ) => Stream.Stream<PubSubMessage, MonitoringError>;
51
+
52
+ /**
53
+ * Subscribe to a Redis Pub/Sub channel
54
+ * @param channel - The channel name to subscribe to
55
+ * @returns Effect that yields a stream of PubSub messages
56
+ */
57
+ readonly subscribe: (
58
+ channel: string,
59
+ ) => Effect.Effect<
60
+ Stream.Stream<PubSubMessage, MonitoringError>,
61
+ MonitoringError
62
+ >;
63
+ }
64
+
65
+ /**
66
+ * Context tag for PubSub monitoring service dependency injection
67
+ */
68
+ export const PubSubMonitorService = Context.GenericTag<PubSubMonitorService>(
69
+ 'PubSubMonitorService',
70
+ );
71
+
72
+ // ============================================================================
73
+ // Helper Functions
74
+ // ============================================================================
75
+
76
+ /**
77
+ * Convert RedisError to MonitoringError
78
+ */
79
+ const convertRedisError = (
80
+ error: RedisError,
81
+ context: string,
82
+ ): MonitoringError => {
83
+ return createSubscriptionFailedError(
84
+ `${context}: ${error.message}`,
85
+ true,
86
+ error,
87
+ );
88
+ };
89
+
90
+ // ============================================================================
91
+ // Service Implementation
92
+ // ============================================================================
93
+
94
+ /**
95
+ * Creates a PubSub monitor service implementation
96
+ */
97
+ const createPubSubMonitorService = (
98
+ redisPubSubService: RedisPubSubService,
99
+ config: RedistailConfig,
100
+ ): PubSubMonitorService => ({
101
+ monitor: (channel: string) =>
102
+ pipe(
103
+ // Create the base subscription stream
104
+ Effect.mapError(
105
+ redisPubSubService.subscribe(channel),
106
+ (error: RedisError) =>
107
+ convertRedisError(
108
+ error,
109
+ `Failed to subscribe to channel '${channel}'`,
110
+ ),
111
+ ),
112
+ // Convert Effect to Stream
113
+ Stream.unwrap,
114
+ // Transform Redis messages to PubSubMessage format
115
+ Stream.map((content: string) =>
116
+ PubSubMessageConstructor({
117
+ timestamp: new Date(),
118
+ channel,
119
+ content,
120
+ }),
121
+ ),
122
+ // Handle stream errors and convert to MonitoringError
123
+ Stream.mapError((error: Error) =>
124
+ createConnectionLostError(
125
+ `Connection lost while monitoring channel '${channel}': ${error.message}`,
126
+ error,
127
+ ),
128
+ ),
129
+ // Add automatic reconnection with exponential backoff
130
+ Stream.retry(
131
+ pipe(
132
+ Schedule.exponential(Duration.seconds(1)),
133
+ Schedule.intersect(
134
+ Schedule.recurs(config.monitoring.maxReconnectAttempts),
135
+ ),
136
+ Schedule.tapInput((error: MonitoringError) =>
137
+ Effect.logWarning(
138
+ `PubSub monitoring error for channel '${channel}': ${error.message}. Retrying...`,
139
+ ),
140
+ ),
141
+ ),
142
+ ),
143
+ // Handle final failure after max retries
144
+ Stream.catchAll((error: MonitoringError) =>
145
+ Stream.fail(
146
+ createSubscriptionFailedError(
147
+ `Failed to monitor channel '${channel}' after ${config.monitoring.maxReconnectAttempts} attempts: ${error.message}`,
148
+ false, // Not retryable after max attempts
149
+ error,
150
+ ),
151
+ ),
152
+ ),
153
+ ),
154
+
155
+ subscribe: (channel: string) =>
156
+ Effect.gen(function* () {
157
+ // Attempt to subscribe to the channel
158
+ const messageStream = yield* Effect.mapError(
159
+ redisPubSubService.subscribe(channel),
160
+ (error: RedisError) =>
161
+ convertRedisError(
162
+ error,
163
+ `Failed to subscribe to channel '${channel}'`,
164
+ ),
165
+ );
166
+
167
+ // Transform the stream to PubSubMessage format
168
+ return pipe(
169
+ messageStream,
170
+ Stream.map((content: string) =>
171
+ PubSubMessageConstructor({
172
+ timestamp: new Date(),
173
+ channel,
174
+ content,
175
+ }),
176
+ ),
177
+ // Handle stream errors
178
+ Stream.mapError((error: Error) =>
179
+ createConnectionLostError(
180
+ `Stream error for channel '${channel}': ${error.message}`,
181
+ error,
182
+ ),
183
+ ),
184
+ );
185
+ }),
186
+ });
187
+
188
+ // ============================================================================
189
+ // Live Layer Implementation
190
+ // ============================================================================
191
+
192
+ /**
193
+ * Live layer for PubSub monitoring service
194
+ * Depends on RedisPubSub and CLIConfigService services
195
+ */
196
+ export const PubSubMonitorServiceLive: Layer.Layer<
197
+ PubSubMonitorService,
198
+ ConfigError,
199
+ RedisPubSub | CLIConfigService
200
+ > = Layer.effect(
201
+ PubSubMonitorService,
202
+ Effect.gen(function* () {
203
+ const redisPubSubService = yield* RedisPubSub;
204
+ const configService = yield* CLIConfigService;
205
+ const config = yield* configService.loadConfig();
206
+ return createPubSubMonitorService(redisPubSubService, config);
207
+ }),
208
+ );
209
+
210
+ // ============================================================================
211
+ // Utility Functions
212
+ // ============================================================================
213
+
214
+ /**
215
+ * Helper function to create a monitoring stream with custom retry configuration
216
+ */
217
+ export const createMonitoringStreamWithRetry = (
218
+ channel: string,
219
+ redisPubSubService: RedisPubSubService,
220
+ maxRetries: number,
221
+ baseDelaySeconds = 1,
222
+ ): Stream.Stream<PubSubMessage, MonitoringError> =>
223
+ pipe(
224
+ Effect.mapError(
225
+ redisPubSubService.subscribe(channel),
226
+ (error: RedisError) =>
227
+ convertRedisError(error, `Failed to subscribe to channel '${channel}'`),
228
+ ),
229
+ Stream.unwrap,
230
+ Stream.map((content: string) =>
231
+ PubSubMessageConstructor({
232
+ timestamp: new Date(),
233
+ channel,
234
+ content,
235
+ }),
236
+ ),
237
+ Stream.mapError((error: Error) =>
238
+ createConnectionLostError(
239
+ `Connection lost while monitoring channel '${channel}': ${error.message}`,
240
+ error,
241
+ ),
242
+ ),
243
+ Stream.retry(
244
+ pipe(
245
+ Schedule.exponential(Duration.seconds(baseDelaySeconds)),
246
+ Schedule.intersect(Schedule.recurs(maxRetries)),
247
+ Schedule.tapInput((error: MonitoringError) =>
248
+ Effect.logWarning(
249
+ `PubSub monitoring error for channel '${channel}': ${error.message}. Retrying...`,
250
+ ),
251
+ ),
252
+ ),
253
+ ),
254
+ );
255
+
256
+ /**
257
+ * Helper function to validate channel names
258
+ */
259
+ export const validateChannelName = (
260
+ channel: string,
261
+ ): Effect.Effect<string, MonitoringError> =>
262
+ Effect.gen(function* () {
263
+ if (!channel || typeof channel !== 'string') {
264
+ return yield* Effect.fail(
265
+ createSubscriptionFailedError(
266
+ 'Channel name must be a non-empty string',
267
+ false,
268
+ ),
269
+ );
270
+ }
271
+
272
+ const trimmedChannel = channel.trim();
273
+ if (trimmedChannel.length === 0) {
274
+ return yield* Effect.fail(
275
+ createSubscriptionFailedError(
276
+ 'Channel name cannot be empty or whitespace only',
277
+ false,
278
+ ),
279
+ );
280
+ }
281
+
282
+ return trimmedChannel;
283
+ });
284
+
285
+ /**
286
+ * Helper function to create a monitored channel with validation
287
+ */
288
+ export const monitorChannelWithValidation = (
289
+ channel: string,
290
+ pubSubService: PubSubMonitorService,
291
+ ): Stream.Stream<PubSubMessage, MonitoringError> =>
292
+ pipe(
293
+ validateChannelName(channel),
294
+ Effect.map((validChannel) => pubSubService.monitor(validChannel)),
295
+ Stream.unwrap,
296
+ );
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Cross-platform launcher for redistail CLI
5
+ * Detects platform and runs the appropriate binary
6
+ */
7
+
8
+ import { spawn } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname, join } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+
16
+ function launchRedistail() {
17
+ const args = process.argv.slice(2);
18
+
19
+ // Try to use the compiled executable first (better performance)
20
+ const exePath = join(__dirname, 'redistail.exe');
21
+ const jsPath = join(__dirname, 'redistail.js');
22
+
23
+ let command;
24
+ let commandArgs;
25
+
26
+ if (process.platform === 'win32' && existsSync(exePath)) {
27
+ // Use the compiled executable on Windows
28
+ command = exePath;
29
+ commandArgs = args;
30
+ } else if (existsSync(jsPath)) {
31
+ // Fall back to Node.js execution
32
+ command = 'node';
33
+ commandArgs = [jsPath, ...args];
34
+ } else {
35
+ console.error('Error: redistail binary not found');
36
+ process.exit(1);
37
+ }
38
+
39
+ // Spawn the process and pipe stdio
40
+ const child = spawn(command, commandArgs, {
41
+ stdio: 'inherit',
42
+ windowsHide: false,
43
+ });
44
+
45
+ // Handle process exit
46
+ child.on('close', (code) => {
47
+ process.exit(code || 0);
48
+ });
49
+
50
+ child.on('error', (error) => {
51
+ console.error('Error launching redistail:', error.message);
52
+ process.exit(1);
53
+ });
54
+
55
+ // Handle process signals
56
+ process.on('SIGINT', () => {
57
+ child.kill('SIGINT');
58
+ });
59
+
60
+ process.on('SIGTERM', () => {
61
+ child.kill('SIGTERM');
62
+ });
63
+ }
64
+
65
+ launchRedistail();