@sprout-idws/sprout-redis 1.0.0 → 1.0.1

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 (2) hide show
  1. package/README.md +351 -0
  2. package/package.json +4 -4
package/README.md ADDED
@@ -0,0 +1,351 @@
1
+ # @sprout-idws/sprout-redis
2
+
3
+ NestJS integration for **Redis** (`ioredis`): shared client, **layered cache** (memory and/or Redis), **distributed locks**, and **streams** (publish, consume, retry, monitoring).
4
+
5
+ ## Purpose
6
+
7
+ - Standardize Redis connection settings from `ConfigService` (`REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`, `REDIS_DB`).
8
+ - Provide injectable building blocks used across Sprout backends: cache abstraction, locking, and stream-based workers.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install @sprout-idws/sprout-redis @sprout-idws/sprout-context ioredis @nestjs/config
14
+ ```
15
+
16
+ Peer dependencies: `@nestjs/common`, `@nestjs/config`, `@sprout-idws/sprout-context`, `ioredis` (v5+).
17
+
18
+ ## Configuration
19
+
20
+ | Variable | Default | Description |
21
+ |----------|---------|-------------|
22
+ | `REDIS_HOST` | `localhost` | Redis host |
23
+ | `REDIS_PORT` | `6379` | Redis port |
24
+ | `REDIS_PASSWORD` | *(none)* | Optional auth |
25
+ | `REDIS_DB` | `0` | Database index |
26
+
27
+ ## Modules and exports
28
+
29
+ ### `RedisBaseModule` (`@Global`)
30
+
31
+ - Provider token **`REDIS_CLIENT`**: `ioredis` instance from env.
32
+ - Exports: `REDIS_CLIENT`, `RedisLockService`, `RedisStreamService`, `StreamPublisherService`.
33
+ - Implements graceful shutdown in `OnModuleDestroy`.
34
+
35
+ Import `ContextModule` from `@sprout-idws/sprout-context` is required internally; ensure your app registers `ContextModule` where stream/cache features need request context.
36
+
37
+ ### `CacheModule` (`@Global`)
38
+
39
+ Builds three `CacheManager` tokens (multi-level LRU + optional Redis):
40
+
41
+ | Token | Behavior |
42
+ |-------|----------|
43
+ | `REDIS_CACHE_MANAGER` | Redis only |
44
+ | `IN_MEMORY_CACHE_MANAGER` | In-memory LRU (max 500 entries) |
45
+ | `IN_MEMORY_THEN_REDIS_CACHE_MANAGER` | Memory first, then Redis |
46
+
47
+ `CacheManager` supports TTL and pluggable `ICacheStore` levels.
48
+
49
+ ### `RedisStreamConsumerModule`
50
+
51
+ Registers stream processing services:
52
+
53
+ - `StreamConsumerService`, `StreamMessageHandlerService`, `StreamRetryService`, `StreamConsumerMonitorService`
54
+
55
+ Use with `RedisBaseModule` for consumers that read Redis streams, handle messages, retries, and monitoring.
56
+
57
+ ### Other notable exports
58
+
59
+ - `RedisLockService` — distributed locks with configurable options.
60
+ - `RedisStreamService`, `StreamPublisherService` — stream I/O helpers.
61
+ - Constants: `REDIS_CACHE_MANAGER`, `IN_MEMORY_CACHE_MANAGER`, `IN_MEMORY_THEN_REDIS_CACHE_MANAGER`, `DEFAULT_CACHE_TTL_SECONDS`, `CONSUMER_INTERVAL_TIME`, and related types.
62
+
63
+ The build copies Lua assets used by the implementation (`scripts/copy-lua.sh`).
64
+
65
+ ## Examples
66
+
67
+ ### Register modules
68
+
69
+ `RedisBaseModule` is global and provides the Redis client, locking, and stream helpers. Add `CacheModule` for cache tokens. For stream consumers (read, retry loop, idle-consumer monitor), import `RedisStreamConsumerModule`.
70
+
71
+ Register **`ConfigModule`** so `REDIS_*` is available, and **`ContextModule`** from `@sprout-idws/sprout-context` wherever publishing or consumer handlers should propagate request context.
72
+
73
+ ```typescript
74
+ import { Module } from '@nestjs/common';
75
+ import { ConfigModule } from '@nestjs/config';
76
+ import { ContextModule } from '@sprout-idws/sprout-context';
77
+ import {
78
+ RedisBaseModule,
79
+ CacheModule,
80
+ RedisStreamConsumerModule,
81
+ } from '@sprout-idws/sprout-redis';
82
+
83
+ @Module({
84
+ imports: [
85
+ ConfigModule.forRoot({ isGlobal: true }),
86
+ ContextModule,
87
+ RedisBaseModule,
88
+ CacheModule,
89
+ RedisStreamConsumerModule, // optional: omit if you only use cache, locks, or publishing
90
+ ],
91
+ })
92
+ export class AppModule {}
93
+ ```
94
+
95
+ ### Inject the Redis client (`REDIS_CLIENT`)
96
+
97
+ Use the shared `ioredis` instance for commands not wrapped by this package.
98
+
99
+ ```typescript
100
+ import { Inject, Injectable } from '@nestjs/common';
101
+ import Redis from 'ioredis';
102
+
103
+ @Injectable()
104
+ export class HealthService {
105
+ constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) {}
106
+
107
+ async ping(): Promise<string> {
108
+ return this.redis.ping();
109
+ }
110
+ }
111
+ ```
112
+
113
+ ### Layered cache (`CacheManager`)
114
+
115
+ Inject one of `REDIS_CACHE_MANAGER`, `IN_MEMORY_CACHE_MANAGER`, or `IN_MEMORY_THEN_REDIS_CACHE_MANAGER`. `get` calls `notFoundFn` when the key is missing at every level, then writes through all levels. `requiredGet` throws if the resolved value is null or undefined. `set` and `delete` apply to every level.
116
+
117
+ ```typescript
118
+ import { Inject, Injectable } from '@nestjs/common';
119
+ import {
120
+ CacheManager,
121
+ IN_MEMORY_THEN_REDIS_CACHE_MANAGER,
122
+ DEFAULT_CACHE_TTL_SECONDS,
123
+ } from '@sprout-idws/sprout-redis';
124
+
125
+ @Injectable()
126
+ export class UserCacheService {
127
+ constructor(
128
+ @Inject(IN_MEMORY_THEN_REDIS_CACHE_MANAGER)
129
+ private readonly cache: CacheManager
130
+ ) {}
131
+
132
+ async getUser(id: string) {
133
+ return this.cache.get(
134
+ `user:${id}`,
135
+ async () => fetchUserFromDb(id),
136
+ { ttlSeconds: DEFAULT_CACHE_TTL_SECONDS }
137
+ );
138
+ }
139
+
140
+ warmUser(id: string, user: unknown) {
141
+ this.cache.set(`user:${id}`, user, { ttlSeconds: 600 });
142
+ }
143
+
144
+ async invalidateUser(id: string) {
145
+ await this.cache.delete(`user:${id}`);
146
+ }
147
+ }
148
+ ```
149
+
150
+ - **`REDIS_CACHE_MANAGER`** — Redis only; shared across app instances.
151
+ - **`IN_MEMORY_CACHE_MANAGER`** — process-local LRU (max 500 entries).
152
+ - **`IN_MEMORY_THEN_REDIS_CACHE_MANAGER`** — memory first; promotes hits to upper levels; on miss, fills memory and Redis.
153
+
154
+ ### Custom `CacheManager` (optional)
155
+
156
+ Build your own stack with `InMemoryCacheStore` and `RedisCacheStore` if you register providers manually (see `cache.module.ts` in this package for the Nest pattern).
157
+
158
+ ```typescript
159
+ import { CacheManager, InMemoryCacheStore, RedisCacheStore } from '@sprout-idws/sprout-redis';
160
+ import Redis from 'ioredis';
161
+
162
+ const redis = new Redis(/* ... */);
163
+ const cache = new CacheManager({
164
+ levels: [new InMemoryCacheStore({ maxSize: 1000 }), new RedisCacheStore(redis)],
165
+ defaultTtlSeconds: 300,
166
+ });
167
+ ```
168
+
169
+ ### Distributed locks (`RedisLockService`)
170
+
171
+ **`runWithLock`** acquires the lock, runs the callback, then releases. If the lock cannot be acquired after optional retries, it returns without invoking the callback.
172
+
173
+ ```typescript
174
+ import { Injectable } from '@nestjs/common';
175
+ import { RedisLockService } from '@sprout-idws/sprout-redis';
176
+
177
+ @Injectable()
178
+ export class ReconciliationService {
179
+ constructor(private readonly locks: RedisLockService) {}
180
+
181
+ async runOncePerCluster() {
182
+ await this.locks.runWithLock(
183
+ 'lock:reconciliation:daily',
184
+ { ttl: 600, retryDelay: 200, maxRetries: 5 },
185
+ async () => {
186
+ // only one holder across processes until TTL expires
187
+ }
188
+ );
189
+ }
190
+ }
191
+ ```
192
+
193
+ **Manual acquire / release** — when you need the lock token (e.g. work split across several steps):
194
+
195
+ ```typescript
196
+ const token = `my-lock:${Date.now()}:${Math.random()}:${process.pid}`;
197
+ const acquired = await this.locks.acquireLock('lock:resource:1', token, {
198
+ ttl: 120,
199
+ maxRetries: 10,
200
+ retryDelay: 50,
201
+ });
202
+ if (acquired) {
203
+ try {
204
+ // ...
205
+ } finally {
206
+ await this.locks.releaseLock('lock:resource:1', token);
207
+ }
208
+ }
209
+ ```
210
+
211
+ ### Publish to a stream
212
+
213
+ **`StreamPublisherService`** — use from HTTP/gRPC handlers: fills `metadata` and attaches the current encoded context from `ContextService`.
214
+
215
+ ```typescript
216
+ import { Injectable } from '@nestjs/common';
217
+ import { StreamPublisherService } from '@sprout-idws/sprout-redis';
218
+
219
+ @Injectable()
220
+ export class OrderEventsService {
221
+ constructor(private readonly publisher: StreamPublisherService) {}
222
+
223
+ async emitCreated(orderId: string) {
224
+ return this.publisher.publishToStream('orders:events', {
225
+ type: 'created',
226
+ orderId,
227
+ });
228
+ }
229
+ }
230
+ ```
231
+
232
+ **`RedisStreamService.publishToStream`** — full `StreamMessage` shape; if `context` is omitted, it defaults to the current encoded context.
233
+
234
+ ```typescript
235
+ import { Injectable } from '@nestjs/common';
236
+ import { RedisStreamService } from '@sprout-idws/sprout-redis';
237
+
238
+ @Injectable()
239
+ export class AdminPublisher {
240
+ constructor(private readonly streams: RedisStreamService) {}
241
+
242
+ async publish() {
243
+ await this.streams.publishToStream('my:stream', {
244
+ data: { action: 'reindex' },
245
+ metadata: {
246
+ timestamp: Date.now(),
247
+ streamName: 'my:stream',
248
+ retryCount: 0,
249
+ },
250
+ });
251
+ }
252
+ }
253
+ ```
254
+
255
+ ### Low-level stream helpers (`RedisStreamService`)
256
+
257
+ Create a consumer group, read pending-to-group messages, and acknowledge (used internally by the consumer stack; useful for custom tooling).
258
+
259
+ ```typescript
260
+ await this.streams.createConsumerGroup('orders:events', 'order-processors', '0');
261
+
262
+ const batch = await this.streams.readFromConsumerGroup({
263
+ streamName: 'orders:events',
264
+ groupName: 'order-processors',
265
+ consumerName: 'worker-1',
266
+ count: 10,
267
+ blockTime: 5000,
268
+ });
269
+
270
+ for (const msg of batch) {
271
+ // process msg.data, msg.context, msg.id
272
+ await this.streams.ackDelete('orders:events', 'order-processors', msg.id!);
273
+ }
274
+
275
+ const streamNames = await this.streams.getAllStreams('orders:*');
276
+ const groups = await this.streams.getConsumerGroups('orders:events');
277
+ const consumers = await this.streams.getConsumers('orders:events', 'order-processors');
278
+ ```
279
+
280
+ `sendToDlq`, `delayedRetry`, `republishDelayedMessages`, and `republishInactiveConsumerMessages` power retries, the DLQ, and the consumer monitor; call them directly only if you extend the same semantics.
281
+
282
+ ### Consume messages (`StreamConsumerService`)
283
+
284
+ Registers the stream’s delayed-retry key, ensures the consumer group exists, then polls every `CONSUMER_INTERVAL_TIME` (1s). The consumer name is suffixed with a unique id. Handler failures trigger retries through `StreamRetryService`; after `maxRetries`, messages go to the DLQ stream `{streamName}:dlq`.
285
+
286
+ ```typescript
287
+ import { Injectable, OnModuleInit } from '@nestjs/common';
288
+ import { StreamConsumerService } from '@sprout-idws/sprout-redis';
289
+
290
+ @Injectable()
291
+ export class OrderConsumer implements OnModuleInit {
292
+ constructor(private readonly consumer: StreamConsumerService) {}
293
+
294
+ onModuleInit() {
295
+ void this.consumer.consumeMessages(
296
+ {
297
+ streamName: 'orders:events',
298
+ groupName: 'order-processors',
299
+ consumerName: 'api-worker',
300
+ maxRetries: 3,
301
+ retryDelay: 5 * 60 * 1000, // ms before retry (scheduled via sorted set)
302
+ blockTime: 2000,
303
+ prefetchCount: 50,
304
+ maxConcurrency: 25,
305
+ },
306
+ async (entry) => {
307
+ // entry.data, entry.context (restored into ContextService for this callback)
308
+ await handleOrderEvent(entry.data);
309
+ }
310
+ );
311
+ }
312
+ }
313
+ ```
314
+
315
+ ### Process a batch yourself (`StreamMessageHandlerService`)
316
+
317
+ Normally used by `StreamConsumerService`. You can call `processMessages` if you already have `StreamMessage[]` (e.g. tests or a custom reader). Exposes `getProcessingMessagesCount()` / `isMessageProcessing(id)` for observability.
318
+
319
+ ```typescript
320
+ await this.messageHandler.processMessages({
321
+ streamName: 'orders:events',
322
+ groupName: 'order-processors',
323
+ maxRetries: 3,
324
+ retryDelay: 300_000,
325
+ maxConcurrency: 10,
326
+ messages: fetchedMessages,
327
+ messageHandler: async (entry) => {
328
+ /* ... */
329
+ },
330
+ });
331
+ ```
332
+
333
+ ### Retries and delayed republish (`StreamRetryService`)
334
+
335
+ When `RedisStreamConsumerModule` loads, `StreamRetryService` starts a periodic job that calls `republishDelayedMessages` on every registered delayed queue (`{streamName}:delayed`). `StreamConsumerService.consumeMessages` registers its stream automatically. You rarely call `delayedRetry` yourself; it is used on handler failure to re-queue the message after `retryDelay`.
336
+
337
+ ### Idle consumers and reclaim (`StreamConsumerMonitorService`)
338
+
339
+ Also starts on module init. Periodically scans Redis for stream keys, lists groups and consumers, and for consumers idle longer than `10 × CONSUMER_INTERVAL_TIME` with pending messages, republishes those messages and removes the stale consumer name. No application code is required beyond importing `RedisStreamConsumerModule`.
340
+
341
+ ## Tests
342
+
343
+ ```bash
344
+ npm test
345
+ ```
346
+
347
+ (from this package directory, or via the monorepo test task)
348
+
349
+ ## Repository
350
+
351
+ [`sprout-typescript-backend`](https://github.com/sprout-libs/sprout-typescript-backend) — `packages/sprout-redis`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprout-idws/sprout-redis",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Reusable NestJS Redis package: cache, lock, and stream modules",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,16 +16,16 @@
16
16
  "format:check": "biome format .",
17
17
  "check": "biome check .",
18
18
  "check:fix": "biome check --write .",
19
- "publish:npm": "npm publish"
19
+ "publish:npm": "bash ../../scripts/publish-npm.sh"
20
20
  },
21
21
  "dependencies": {
22
- "@sprout-idws/sprout-context": "^1.0.0",
22
+ "@sprout-idws/sprout-context": "^1.0.1",
23
23
  "lru-cache": "^10.0.0"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "@nestjs/common": ">=10.0.0",
27
27
  "@nestjs/config": ">=3.0.0",
28
- "@sprout-idws/sprout-context": "^1.0.0",
28
+ "@sprout-idws/sprout-context": "^1.0.1",
29
29
  "ioredis": ">=5.0.0"
30
30
  },
31
31
  "devDependencies": {