snugkv-nest 0.0.0-stage → 0.2.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +269 -2
  3. package/dist/core/bus.d.ts +61 -0
  4. package/dist/core/bus.js +110 -0
  5. package/dist/core/bus.js.map +1 -0
  6. package/dist/core/cache.d.ts +27 -0
  7. package/dist/core/cache.js +71 -0
  8. package/dist/core/cache.js.map +1 -0
  9. package/dist/core/client.d.ts +194 -0
  10. package/dist/core/client.js +458 -0
  11. package/dist/core/client.js.map +1 -0
  12. package/dist/core/connection.d.ts +63 -0
  13. package/dist/core/connection.js +304 -0
  14. package/dist/core/connection.js.map +1 -0
  15. package/dist/core/consumer.d.ts +118 -0
  16. package/dist/core/consumer.js +391 -0
  17. package/dist/core/consumer.js.map +1 -0
  18. package/dist/core/index.d.ts +13 -0
  19. package/dist/core/index.js +34 -0
  20. package/dist/core/index.js.map +1 -0
  21. package/dist/core/lock.d.ts +38 -0
  22. package/dist/core/lock.js +68 -0
  23. package/dist/core/lock.js.map +1 -0
  24. package/dist/core/presence.d.ts +52 -0
  25. package/dist/core/presence.js +80 -0
  26. package/dist/core/presence.js.map +1 -0
  27. package/dist/core/publisher.d.ts +51 -0
  28. package/dist/core/publisher.js +88 -0
  29. package/dist/core/publisher.js.map +1 -0
  30. package/dist/core/pubsub.d.ts +28 -0
  31. package/dist/core/pubsub.js +134 -0
  32. package/dist/core/pubsub.js.map +1 -0
  33. package/dist/core/ratelimit.d.ts +16 -0
  34. package/dist/core/ratelimit.js +30 -0
  35. package/dist/core/ratelimit.js.map +1 -0
  36. package/dist/core/resp.d.ts +15 -0
  37. package/dist/core/resp.js +101 -0
  38. package/dist/core/resp.js.map +1 -0
  39. package/dist/core/tx.d.ts +24 -0
  40. package/dist/core/tx.js +52 -0
  41. package/dist/core/tx.js.map +1 -0
  42. package/dist/core/util.d.ts +20 -0
  43. package/dist/core/util.js +55 -0
  44. package/dist/core/util.js.map +1 -0
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.js +19 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/nest/decorators.d.ts +34 -0
  49. package/dist/nest/decorators.js +101 -0
  50. package/dist/nest/decorators.js.map +1 -0
  51. package/dist/nest/explorer.d.ts +10 -0
  52. package/dist/nest/explorer.js +41 -0
  53. package/dist/nest/explorer.js.map +1 -0
  54. package/dist/nest/index.d.ts +5 -0
  55. package/dist/nest/index.js +26 -0
  56. package/dist/nest/index.js.map +1 -0
  57. package/dist/nest/module.d.ts +26 -0
  58. package/dist/nest/module.js +83 -0
  59. package/dist/nest/module.js.map +1 -0
  60. package/dist/nest/runtime.d.ts +19 -0
  61. package/dist/nest/runtime.js +72 -0
  62. package/dist/nest/runtime.js.map +1 -0
  63. package/dist/nest/tokens.d.ts +1 -0
  64. package/dist/nest/tokens.js +5 -0
  65. package/dist/nest/tokens.js.map +1 -0
  66. package/package.json +75 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Edijs Ziedins
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,270 @@
1
- # Temporary Holding Version
1
+ # snugkv-nest
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The NestJS module and TypeScript client for [SnugKV](https://github.com/edijs1984/snugkv): strings, hashes, lists, sets, sorted sets, expiry, Lua, pub/sub and transactions, plus cache, lock and rate-limit helpers and a durable message bus on streams.
4
+
5
+ - [Using SnugKV as a data store](#using-snugkv-as-a-data-store)
6
+ - [Messaging (streams)](#messaging-streams)
7
+
8
+ - **Durable topics.** Messages are stream entries. They survive restarts and are replayable.
9
+ - **Consumer groups.** Instances of one service share the work; every different service gets every message.
10
+ - **Safe delivery.** At-least-once with acknowledgements, retries with backoff, dead-letter topics, and automatic takeover of messages a crashed instance left behind.
11
+ - **No dependencies.** A small built-in RESP client (reconnect, AUTH, TLS, pipelining). Only `@nestjs/common` and `@nestjs/core` as peers.
12
+ - **Visible.** Every publisher and consumer reports itself, so [snugkv-stream-ui](https://github.com/edijs1984/snugkv-stream-ui) can show who publishes and who listens to each topic.
13
+
14
+ It also works as a plain Redis-protocol streams client, so it should run against Redis 6.2+ too, but SnugKV is what it is tested with.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install snugkv-nest
20
+ ```
21
+
22
+ Needs Node 18+ and NestJS 10 or 11.
23
+
24
+ ## Using SnugKV as a data store
25
+
26
+ Import the module once; everything below can be injected.
27
+
28
+ ```ts
29
+ import { Injectable } from '@nestjs/common';
30
+ import { SnugKvCache, SnugKvClient, SnugKvLocks, SnugKvModule, SnugKvRateLimiter, SnugKvSubscriber } from 'snugkv-nest';
31
+
32
+ // app.module.ts
33
+ @Module({ imports: [SnugKvModule.forRoot({ url: process.env.SNUGKV_URL, cache: { prefix: 'app:', ttlSeconds: 120 } })] })
34
+ export class AppModule {}
35
+
36
+ @Injectable()
37
+ export class UsersService {
38
+ constructor(
39
+ private readonly kv: SnugKvClient,
40
+ private readonly cache: SnugKvCache,
41
+ private readonly locks: SnugKvLocks,
42
+ private readonly limiter: SnugKvRateLimiter,
43
+ private readonly pubsub: SnugKvSubscriber,
44
+ ) {}
45
+
46
+ profile(id: string) {
47
+ return this.cache.wrap(`user:${id}`, () => this.loadFromDb(id)); // loader runs once, result cached 120 s
48
+ }
49
+
50
+ async rebuild() {
51
+ const r = await this.locks.run('rebuild', () => this.doRebuild(), { ttlMs: 60_000 });
52
+ return r.acquired;
53
+ }
54
+
55
+ async allow(ip: string) {
56
+ return (await this.limiter.hit(ip, 100, 60_000)).allowed; // 100 per minute
57
+ }
58
+ }
59
+ ```
60
+
61
+ `service` is only needed for messaging; without it the module uses `app`. Outside Nest, use `new SnugKvClient({ url })` from `snugkv-nest/core`.
62
+
63
+ ### Client
64
+
65
+ ```ts
66
+ await kv.set('a', '1'); await kv.setOpts('a', '1', { nx: true, exSeconds: 30 });
67
+ await kv.incr('visits'); await kv.setJson('cfg', { a: 1 }); await kv.getJson('cfg');
68
+ await kv.hset('user:1', { name: 'Ann', age: 31 }); await kv.hgetall('user:1');
69
+ await kv.rpush('queue', 'a', 'b'); await kv.lrange('queue');
70
+ await kv.sadd('tags', 'x', 'y'); await kv.smembers('tags');
71
+ await kv.zadd('board', [{ member: 'ann', score: 10 }]); await kv.zrange('board', 0, 9, { reverse: true });
72
+ await kv.expire('a', 60); await kv.ttl('a');
73
+ for await (const key of kv.scanIterator({ match: 'user:*' })) { /* … */ }
74
+ await kv.eval("return redis.call('get', KEYS[1])", ['a']);
75
+ await kv.tx().set('a', '1').incr('n').hset('h', { f: 1 }).exec(); // MULTI/EXEC in one write
76
+ await kv.call('ANYTHING', 'else'); // raw command
77
+ ```
78
+
79
+ ### Pub/sub
80
+
81
+ Fire-and-forget broadcast. Use the streams below when a message must not be lost.
82
+
83
+ ```ts
84
+ const off = await pubsub.subscribe('news', (message, channel) => console.log(channel, message));
85
+ await pubsub.psubscribe('events.*', (message, channel, pattern) => { /* … */ });
86
+ await kv.publish('news', 'hello');
87
+ await off();
88
+ ```
89
+
90
+ Subscriptions are restored after a reconnect; messages sent while disconnected are not replayed.
91
+
92
+ ### Cache, locks, rate limits
93
+
94
+ - `SnugKvCache`: `get`, `set`, `del`, `wrap(key, loader, ttl)` (concurrent callers share one load), `clear(pattern)`.
95
+ - `SnugKvLocks`: `acquire(name, { ttlMs, waitMs })`, `run(name, fn)`. A lock expires on its own, and only its holder can release or extend it. It protects against crashed holders, not against the server itself failing, so keep critical sections idempotent.
96
+ - `SnugKvRateLimiter`: `hit(key, limit, windowMs)` is a fixed window; it returns `{ allowed, count, remaining, resetMs }`.
97
+
98
+ ## Messaging (streams)
99
+
100
+ ## Quick start
101
+
102
+ ```ts
103
+ // app.module.ts
104
+ import { Module } from '@nestjs/common';
105
+ import { SnugKvModule } from 'snugkv-nest';
106
+
107
+ @Module({
108
+ imports: [
109
+ SnugKvModule.forRoot({
110
+ url: 'redis://localhost:6379',
111
+ service: 'billing', // this microservice's name
112
+ }),
113
+ ],
114
+ })
115
+ export class AppModule {}
116
+ ```
117
+
118
+ Publish:
119
+
120
+ ```ts
121
+ import { Injectable } from '@nestjs/common';
122
+ import { SnugKvBus } from 'snugkv-nest';
123
+
124
+ @Injectable()
125
+ export class OrdersService {
126
+ constructor(private readonly bus: SnugKvBus) {}
127
+
128
+ async create(order: Order) {
129
+ await this.bus.publish('orders.created', order, { event: 'order.created', headers: { tenant: 'acme' } });
130
+ }
131
+ }
132
+ ```
133
+
134
+ Consume:
135
+
136
+ ```ts
137
+ import { Injectable } from '@nestjs/common';
138
+ import { SnugSubscribe, MessageContext } from 'snugkv-nest';
139
+
140
+ @Injectable()
141
+ export class BillingHandlers {
142
+ @SnugSubscribe('orders.created', { concurrency: 4 })
143
+ async onOrder(order: Order, ctx: MessageContext) {
144
+ // throw to retry; after maxAttempts the message goes to "orders.created.dlq"
145
+ }
146
+ }
147
+ ```
148
+
149
+ Any provider or controller with `@SnugSubscribe` methods is picked up when the app starts. Consumers stop cleanly when the app closes (`app.enableShutdownHooks()` makes this work on SIGTERM).
150
+
151
+ Prefer parameter decorators?
152
+
153
+ ```ts
154
+ @SnugSubscribe('orders.created')
155
+ onOrder(@SnugPayload() order: Order, @SnugHeader('tenant') tenant: string, @SnugContext() ctx: MessageContext) {}
156
+ ```
157
+
158
+ ## How delivery works
159
+
160
+ | You want | Do this |
161
+ | --- | --- |
162
+ | Run 3 copies of `billing`, each message handled once | Same `service` name on all copies |
163
+ | `mailer` and `billing` both react to the same event | Different `service` names |
164
+ | Two handlers in one service on one topic | Give one a different `group` |
165
+
166
+ The consumer group defaults to the `service` name. Each running copy is a consumer named `<service>-<instance>`.
167
+
168
+ **Retries.** A handler that throws is called again after a delay (200 ms doubling to 10 s) up to `maxAttempts` (3). Then the message is copied to `<topic>.dlq` with `dlq_error`, `dlq_attempts`, `dlq_source`, `dlq_group` and `dlq_id`, and acknowledged. `ctx.attempt` tells the handler which try it is.
169
+
170
+ **Crashes.** Messages a consumer read but never acknowledged stay pending. Every consumer checks for pending messages idle longer than `claimMinIdleMs` (60 s) and takes them over. A message that has already been delivered `maxAttempts` times goes straight to the dead-letter topic, so a message that kills the process cannot loop forever. Set `claimMinIdleMs` above your slowest handler.
171
+
172
+ **Order.** `concurrency: 1` (the default) handles messages one at a time in stream order. Higher values trade order for throughput.
173
+
174
+ **At-least-once.** After a crash a message can be handled twice. Make handlers idempotent; `ctx.messageId` is stable across redeliveries.
175
+
176
+ **Manual ack.** With `autoAck: false` the message stays pending until the handler calls `await ctx.ack()`.
177
+
178
+ ## Options
179
+
180
+ ### `SnugKvModule.forRoot(options)` / `SnugKvBus`
181
+
182
+ | Option | Default | |
183
+ | --- | --- | --- |
184
+ | `service` | required | Name of this microservice. Default consumer group. |
185
+ | `url` | | `redis://user:pass@host:port`, `rediss://` for TLS. `snugkv://` and `snugkvs://` also work. |
186
+ | `host`, `port`, `username`, `password`, `tls` | `127.0.0.1`, `6379` | Instead of `url`. |
187
+ | `instance` | `<hostname>-<random>` | Id of this running copy. Use the pod name for fast recovery after a restart. |
188
+ | `keyPrefix` | none | Prepended to every topic to form the stream key. |
189
+ | `maxLen` | none | Trim published streams to about this many entries. |
190
+ | `presence` | `true` | Report publishers and consumers for the UI. `false` turns it off. |
191
+ | `isGlobal` | `true` | Make the module global. |
192
+ | `autoSubscribe` | `true` | Start `@SnugSubscribe` consumers automatically. |
193
+
194
+ `forRootAsync({ imports, inject, useFactory })` works as usual:
195
+
196
+ ```ts
197
+ SnugKvModule.forRootAsync({
198
+ inject: [ConfigService],
199
+ useFactory: (config: ConfigService) => ({ url: config.get('SNUGKV_URL')!, service: 'billing' }),
200
+ });
201
+ ```
202
+
203
+ ### `@SnugSubscribe(topic, options)`
204
+
205
+ | Option | Default | |
206
+ | --- | --- | --- |
207
+ | `group` | `service` | Consumer group. |
208
+ | `consumer` | `<service>-<instance>` | Consumer name. |
209
+ | `startFrom` | `'$'` | Where a new group starts: `'$'` new messages only, `'0'` from the oldest retained. |
210
+ | `concurrency` | `1` | Messages handled at once. |
211
+ | `prefetch` | `max(10, 2×concurrency)` | Messages read ahead. |
212
+ | `autoAck` | `true` | Acknowledge when the handler returns. |
213
+ | `maxAttempts` | `3` | Handler runs before dead-lettering. |
214
+ | `retryDelayMs` | 200 ms × 2ⁿ, max 10 s | Number, or `(failedAttempt) => ms`. |
215
+ | `deadLetterTopic` | `<topic>.dlq` | `false` drops failed messages after logging. |
216
+ | `claimMinIdleMs` | `60000` | Take over messages idle this long. |
217
+ | `claimIntervalMs` | `15000` | How often to look. |
218
+ | `shutdownTimeoutMs` | `10000` | How long to wait for running handlers on stop. |
219
+
220
+ ### Publishing
221
+
222
+ ```ts
223
+ bus.publish(topic, payload, { event?, headers?, maxLen? }): Promise<string> // entry id
224
+ bus.publishBatch(topic, [{ payload, event?, headers? }, …]): Promise<string[]> // one round trip
225
+ ```
226
+
227
+ Each entry has the fields `payload` (JSON), `event`, `mid` (message uuid), `ts` (ms), `producer` (`service/instance`) and optionally `headers` (JSON). Entries written by other tools are delivered too; without a `payload` field the handler gets an object of all fields.
228
+
229
+ ### Raw commands and health
230
+
231
+ `SnugKvClient` is injectable for anything else:
232
+
233
+ ```ts
234
+ constructor(private readonly kv: SnugKvClient) {}
235
+ await this.kv.call('XLEN', 'orders.created');
236
+ await this.kv.ping(); // use in a health check
237
+ ```
238
+
239
+ ## Without Nest
240
+
241
+ ```ts
242
+ import { SnugKvBus } from 'snugkv-nest/core';
243
+
244
+ const bus = new SnugKvBus({ url: 'redis://localhost:6379', service: 'worker' });
245
+ const sub = await bus.subscribe({ topic: 'jobs' }, async (job) => { /* … */ });
246
+ await bus.publish('jobs', { id: 1 });
247
+ await bus.close(); // stops consumers, removes presence records
248
+ ```
249
+
250
+ ## Presence protocol
251
+
252
+ For tools that want to show who is connected: each publisher and consumer keeps a JSON string at
253
+ `snugkv:presence:<role>:<topic>:<service-or-group>:<instance>` with a 15 s expiry, refreshed every 5 s. Fields:
254
+ `v, role, service, instance, topic, group?, consumer?, host, pid, startedAt, updatedAt` and counters
255
+ `published?, processed?, failed?, deadLettered?, inflight?, concurrency?`.
256
+
257
+ ## Development
258
+
259
+ ```bash
260
+ git clone https://github.com/edijs1984/snugkv && (cd snugkv && go build -o /tmp/snugkv ./cmd/snugkv)
261
+ npm install
262
+ npm run build
263
+ SNUGKV_BIN=/tmp/snugkv npm test # tests start real servers on free ports
264
+ ```
265
+
266
+ Tests that need a server are skipped when `SNUGKV_BIN` is not set.
267
+
268
+ ## License
269
+
270
+ MIT
@@ -0,0 +1,61 @@
1
+ import { SnugKvClient, SnugKvClientOptions } from './client';
2
+ import { MessageHandler, SubscribeOptions, ConsumerStats } from './consumer';
3
+ import { SnugKvSubscriber } from './pubsub';
4
+ import { PresenceOptions } from './presence';
5
+ import { BatchMessage, PublishOptions, Publisher, Serializer } from './publisher';
6
+ import { BusLogger } from './util';
7
+ export interface SnugKvBusOptions extends SnugKvClientOptions {
8
+ /** Name of this microservice, e.g. `billing`. Default consumer group. */
9
+ service: string;
10
+ /** Unique id of this running copy. Default `<hostname>-<random>`. Use a stable id (e.g. the pod name) for faster crash recovery. */
11
+ instance?: string;
12
+ /** Prepended to every topic to form the stream key. Default none. */
13
+ keyPrefix?: string;
14
+ /** Trim published streams to about this many entries. Default: no trimming. */
15
+ maxLen?: number;
16
+ /** Report this process to tools like the stream UI. Default true. */
17
+ presence?: boolean | PresenceOptions;
18
+ serializer?: Serializer;
19
+ logger?: BusLogger;
20
+ /** Wait this long for the first connection in `connect()`. Default 10 s. */
21
+ connectTimeoutMs?: number;
22
+ }
23
+ export interface Subscription {
24
+ readonly topic: string;
25
+ readonly group: string;
26
+ readonly consumer: string;
27
+ stats(): ConsumerStats;
28
+ stop(): Promise<void>;
29
+ }
30
+ /** Everything in one object: publish, subscribe, presence, shutdown. */
31
+ export declare class SnugKvBus {
32
+ private readonly options;
33
+ readonly client: SnugKvClient;
34
+ readonly service: string;
35
+ readonly instance: string;
36
+ readonly publisher: Publisher;
37
+ private readonly presence?;
38
+ private readonly consumers;
39
+ private readonly serializer;
40
+ private readonly logger;
41
+ private readonly keyPrefix;
42
+ private closed;
43
+ private pubsub?;
44
+ constructor(options: SnugKvBusOptions);
45
+ /** Connects the main connection. Rejects if the server is not reachable in time. */
46
+ connect(): Promise<void>;
47
+ publish<T = unknown>(topic: string, payload: T, options?: PublishOptions): Promise<string>;
48
+ publishBatch<T = unknown>(topic: string, messages: Array<BatchMessage<T>>, options?: {
49
+ maxLen?: number;
50
+ }): Promise<string[]>;
51
+ /** Starts a consumer. Resolves when it is reading. */
52
+ subscribe<T = unknown>(options: SubscribeOptions, handler: MessageHandler<T>): Promise<Subscription>;
53
+ /** Shared pub/sub subscriber (own connection, opened on first use, closed with the bus). */
54
+ subscriber(): SnugKvSubscriber;
55
+ stats(): {
56
+ published: Record<string, number>;
57
+ consumers: ConsumerStats[];
58
+ };
59
+ /** Stops consumers (letting running handlers finish), removes presence records and closes connections. */
60
+ close(): Promise<void>;
61
+ }
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SnugKvBus = void 0;
4
+ const node_os_1 = require("node:os");
5
+ const client_1 = require("./client");
6
+ const consumer_1 = require("./consumer");
7
+ const presence_1 = require("./presence");
8
+ const publisher_1 = require("./publisher");
9
+ const util_1 = require("./util");
10
+ /** Everything in one object: publish, subscribe, presence, shutdown. */
11
+ class SnugKvBus {
12
+ options;
13
+ client;
14
+ service;
15
+ instance;
16
+ publisher;
17
+ presence;
18
+ consumers = [];
19
+ serializer;
20
+ logger;
21
+ keyPrefix;
22
+ closed = false;
23
+ pubsub;
24
+ constructor(options) {
25
+ this.options = options;
26
+ if (!options.service)
27
+ throw new Error('snugkv: "service" is required');
28
+ this.service = options.service;
29
+ this.instance = options.instance ?? `${(0, node_os_1.hostname)()}-${(0, util_1.uuid)().slice(0, 8)}`;
30
+ this.serializer = options.serializer ?? publisher_1.jsonSerializer;
31
+ this.logger = options.logger ?? util_1.consoleLogger;
32
+ this.keyPrefix = options.keyPrefix ?? '';
33
+ const { service, instance, keyPrefix, maxLen, presence, serializer, logger, connectTimeoutMs, ...conn } = options;
34
+ this.client = new client_1.SnugKvClient({ name: `${this.service}/${this.instance}`, ...conn });
35
+ this.client.on('error', (e) => this.logger.warn(`connection error: ${e.message}`));
36
+ if (options.presence !== false) {
37
+ this.presence = new presence_1.PresenceReporter(this.client, this.logger, typeof options.presence === 'object' ? options.presence : {});
38
+ }
39
+ this.publisher = new publisher_1.Publisher(this.client, { service: this.service, instance: this.instance, keyPrefix: this.keyPrefix, defaultMaxLen: options.maxLen, serializer: this.serializer }, this.presence);
40
+ }
41
+ /** Connects the main connection. Rejects if the server is not reachable in time. */
42
+ async connect() {
43
+ await this.client.connect(this.options.connectTimeoutMs ?? 10_000);
44
+ }
45
+ publish(topic, payload, options) {
46
+ return this.publisher.publish(topic, payload, options);
47
+ }
48
+ publishBatch(topic, messages, options) {
49
+ return this.publisher.publishBatch(topic, messages, options);
50
+ }
51
+ /** Starts a consumer. Resolves when it is reading. */
52
+ async subscribe(options, handler) {
53
+ if (this.closed)
54
+ throw new Error('snugkv: bus is closed');
55
+ await this.connect();
56
+ const read = this.client.duplicate({ name: `${this.service}/${this.instance}:read:${options.topic}` });
57
+ read.on('error', (e) => this.logger.warn(`read connection error: ${e.message}`));
58
+ const consumer = new consumer_1.Consumer(options, handler, {
59
+ cmd: this.client,
60
+ read,
61
+ service: this.service,
62
+ instance: this.instance,
63
+ keyPrefix: this.keyPrefix,
64
+ serializer: this.serializer,
65
+ logger: this.logger,
66
+ presence: this.presence,
67
+ });
68
+ this.consumers.push(consumer);
69
+ try {
70
+ await consumer.start();
71
+ }
72
+ catch (err) {
73
+ this.consumers.splice(this.consumers.indexOf(consumer), 1);
74
+ read.close();
75
+ throw err;
76
+ }
77
+ return {
78
+ topic: consumer.topic,
79
+ group: consumer.group,
80
+ consumer: consumer.consumer,
81
+ stats: () => consumer.stats(),
82
+ stop: async () => {
83
+ const i = this.consumers.indexOf(consumer);
84
+ if (i >= 0)
85
+ this.consumers.splice(i, 1);
86
+ await consumer.stop();
87
+ },
88
+ };
89
+ }
90
+ /** Shared pub/sub subscriber (own connection, opened on first use, closed with the bus). */
91
+ subscriber() {
92
+ this.pubsub ??= this.client.subscriber();
93
+ return this.pubsub;
94
+ }
95
+ stats() {
96
+ return { published: this.publisher.stats(), consumers: this.consumers.map((c) => c.stats()) };
97
+ }
98
+ /** Stops consumers (letting running handlers finish), removes presence records and closes connections. */
99
+ async close() {
100
+ if (this.closed)
101
+ return;
102
+ this.closed = true;
103
+ await Promise.allSettled(this.consumers.splice(0).map((c) => c.stop()));
104
+ await this.presence?.stop();
105
+ this.pubsub?.close();
106
+ this.client.close();
107
+ }
108
+ }
109
+ exports.SnugKvBus = SnugKvBus;
110
+ //# sourceMappingURL=bus.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bus.js","sourceRoot":"","sources":["../../src/core/bus.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ import type { SnugKvClient } from './client';
2
+ export interface CacheOptions {
3
+ /** Prefix for every key. Default `cache:`. */
4
+ prefix?: string;
5
+ /** Default time to live in seconds. Default 60. */
6
+ ttlSeconds?: number;
7
+ }
8
+ /**
9
+ * JSON cache on top of strings with expiry. `wrap` runs the loader at most
10
+ * once at a time per key inside this process (concurrent callers share the
11
+ * result).
12
+ */
13
+ export declare class SnugKvCache {
14
+ private readonly client;
15
+ private readonly prefix;
16
+ private readonly ttl;
17
+ private readonly inflight;
18
+ constructor(client: SnugKvClient, opts?: CacheOptions);
19
+ key(key: string): string;
20
+ get<T = unknown>(key: string): Promise<T | undefined>;
21
+ set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
22
+ del(...keys: string[]): Promise<number>;
23
+ /** Returns the cached value, or runs `load`, stores the result and returns it. `undefined` results are not cached. */
24
+ wrap<T>(key: string, load: () => Promise<T> | T, ttlSeconds?: number): Promise<T>;
25
+ /** Deletes every key under the cache prefix that matches `pattern` (default all). */
26
+ clear(pattern?: string): Promise<number>;
27
+ }
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SnugKvCache = void 0;
4
+ /**
5
+ * JSON cache on top of strings with expiry. `wrap` runs the loader at most
6
+ * once at a time per key inside this process (concurrent callers share the
7
+ * result).
8
+ */
9
+ class SnugKvCache {
10
+ client;
11
+ prefix;
12
+ ttl;
13
+ inflight = new Map();
14
+ constructor(client, opts = {}) {
15
+ this.client = client;
16
+ this.prefix = opts.prefix ?? 'cache:';
17
+ this.ttl = opts.ttlSeconds ?? 60;
18
+ }
19
+ key(key) {
20
+ return this.prefix + key;
21
+ }
22
+ async get(key) {
23
+ const raw = await this.client.get(this.key(key));
24
+ return raw === null ? undefined : JSON.parse(raw);
25
+ }
26
+ async set(key, value, ttlSeconds = this.ttl) {
27
+ await this.client.setJson(this.key(key), value, ttlSeconds);
28
+ }
29
+ async del(...keys) {
30
+ return keys.length ? this.client.del(...keys.map((k) => this.key(k))) : 0;
31
+ }
32
+ /** Returns the cached value, or runs `load`, stores the result and returns it. `undefined` results are not cached. */
33
+ async wrap(key, load, ttlSeconds = this.ttl) {
34
+ const hit = await this.get(key);
35
+ if (hit !== undefined)
36
+ return hit;
37
+ const running = this.inflight.get(key);
38
+ if (running)
39
+ return running;
40
+ const p = (async () => {
41
+ try {
42
+ const value = await load();
43
+ if (value !== undefined)
44
+ await this.set(key, value, ttlSeconds);
45
+ return value;
46
+ }
47
+ finally {
48
+ this.inflight.delete(key);
49
+ }
50
+ })();
51
+ this.inflight.set(key, p);
52
+ return p;
53
+ }
54
+ /** Deletes every key under the cache prefix that matches `pattern` (default all). */
55
+ async clear(pattern = '*') {
56
+ let n = 0;
57
+ let batch = [];
58
+ for await (const k of this.client.scanIterator({ match: this.prefix + pattern, count: 200 })) {
59
+ batch.push(k);
60
+ if (batch.length >= 200) {
61
+ n += await this.client.del(...batch);
62
+ batch = [];
63
+ }
64
+ }
65
+ if (batch.length)
66
+ n += await this.client.del(...batch);
67
+ return n;
68
+ }
69
+ }
70
+ exports.SnugKvCache = SnugKvCache;
71
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/core/cache.ts"],"names":[],"mappings":"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"}