@blokjs/trigger-pubsub 0.2.3 → 0.6.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 (36) hide show
  1. package/__tests__/integration/gcp-pubsub.real-emulator.test.ts +235 -0
  2. package/__tests__/integration/kafka-pubsub.real-kafka.test.ts +269 -0
  3. package/__tests__/integration/nats-pubsub.real-nats.test.ts +138 -0
  4. package/dist/PubSubTrigger.d.ts +43 -3
  5. package/dist/PubSubTrigger.js +70 -16
  6. package/dist/adapters/AWSSNSAdapter.d.ts +16 -0
  7. package/dist/adapters/AWSSNSAdapter.js +52 -9
  8. package/dist/adapters/AzureServiceBusAdapter.d.ts +15 -0
  9. package/dist/adapters/AzureServiceBusAdapter.js +44 -11
  10. package/dist/adapters/GCPPubSubAdapter.d.ts +16 -0
  11. package/dist/adapters/GCPPubSubAdapter.js +42 -8
  12. package/dist/adapters/KafkaPubSubAdapter.d.ts +53 -0
  13. package/dist/adapters/KafkaPubSubAdapter.js +168 -0
  14. package/dist/adapters/NATSPubSubAdapter.d.ts +52 -0
  15. package/dist/adapters/NATSPubSubAdapter.js +260 -0
  16. package/dist/adapters/RedisStreamsPubSubAdapter.d.ts +49 -0
  17. package/dist/adapters/RedisStreamsPubSubAdapter.js +193 -0
  18. package/dist/adapters/factory.d.ts +22 -0
  19. package/dist/adapters/factory.js +80 -0
  20. package/dist/index.d.ts +36 -45
  21. package/dist/index.js +39 -46
  22. package/package.json +22 -10
  23. package/src/PubSubTrigger.ts +84 -18
  24. package/src/adapters/AWSSNSAdapter.ts +76 -12
  25. package/src/adapters/AzureServiceBusAdapter.ts +57 -14
  26. package/src/adapters/GCPPubSubAdapter.ts +50 -10
  27. package/src/adapters/KafkaPubSubAdapter.ts +194 -0
  28. package/src/adapters/NATSPubSubAdapter.ts +326 -0
  29. package/src/adapters/RedisStreamsPubSubAdapter.ts +225 -0
  30. package/src/adapters/factory.test.ts +87 -0
  31. package/src/adapters/factory.ts +88 -0
  32. package/src/adapters/new-adapters.test.ts +108 -0
  33. package/src/index.ts +40 -41
  34. package/template/package.json +6 -6
  35. package/template/src/runner/PubSubServer.ts +2 -2
  36. package/template/src/workflows/messages/on-message.ts +38 -34
@@ -0,0 +1,326 @@
1
+ /**
2
+ * NATSPubSubAdapter — v0.7 PR 6 — Pub/Sub adapter backed by NATS.
3
+ *
4
+ * Two modes:
5
+ * - **Fan-out** (default, when `consumerGroup` is absent): every
6
+ * subscriber receives every message on the subject. NATS Core
7
+ * publish/subscribe semantics — cheapest path, no persistence.
8
+ * - **Competing-consumer** (when `consumerGroup` is set): NATS
9
+ * Queue Group — exactly one subscriber in the named group
10
+ * receives each message. Pure NATS Core.
11
+ * - **Durable** (when `durable: true`): subscribe via NATS
12
+ * JetStream consumer so the subscription survives restarts and
13
+ * replays missed messages from `startFrom`. Required for the
14
+ * `{seq}` / `{timestamp}` replay cursors.
15
+ *
16
+ * Subject wildcards (`orders.*.created`, `orders.>`) are honored by
17
+ * NATS natively in both modes.
18
+ *
19
+ * Requires `nats` as a peer dependency:
20
+ *
21
+ * bun add nats
22
+ *
23
+ * Environment variables:
24
+ * - `NATS_SERVERS` — comma-separated URLs (default `localhost:4222`).
25
+ * - `NATS_TOKEN` — bearer token authentication.
26
+ * - `NATS_USER` / `NATS_PASS` — userpass authentication.
27
+ */
28
+
29
+ import type { PubSubTriggerOpts } from "@blokjs/helper";
30
+ import { v4 as uuid } from "uuid";
31
+ import type { PubSubAdapter, PubSubMessage } from "../PubSubTrigger";
32
+
33
+ export interface NATSPubSubConfig {
34
+ servers: string[];
35
+ token?: string;
36
+ user?: string;
37
+ pass?: string;
38
+ }
39
+
40
+ interface NatsSubscription {
41
+ unsubscribe: () => void | Promise<void>;
42
+ }
43
+
44
+ interface NatsConnection {
45
+ close: () => Promise<void>;
46
+ drain: () => Promise<void>;
47
+ subscribe: (
48
+ subject: string,
49
+ opts?: { queue?: string; callback?: (err: Error | null, msg: NatsMsg) => void },
50
+ ) => NatsSubscription;
51
+ publish: (subject: string, payload: Uint8Array) => void;
52
+ jetstream?: () => NatsJetStream;
53
+ jetstreamManager?: () => Promise<NatsJetStreamManager>;
54
+ }
55
+
56
+ interface NatsJetStream {
57
+ subscribe: (
58
+ subject: string,
59
+ opts?: unknown,
60
+ ) => Promise<{
61
+ [Symbol.asyncIterator]: () => AsyncIterator<NatsJsMsg>;
62
+ unsubscribe: () => Promise<void> | void;
63
+ }>;
64
+ publish: (subject: string, payload: Uint8Array) => Promise<unknown>;
65
+ }
66
+
67
+ interface NatsJetStreamManager {
68
+ streams: { add: (config: unknown) => Promise<unknown>; info: (name: string) => Promise<unknown> };
69
+ consumers: { add: (stream: string, config: unknown) => Promise<unknown> };
70
+ }
71
+
72
+ interface NatsMsg {
73
+ subject: string;
74
+ data: Uint8Array;
75
+ sid: number;
76
+ respond?: (data?: Uint8Array) => boolean;
77
+ }
78
+
79
+ interface NatsJsMsg {
80
+ subject: string;
81
+ data: Uint8Array;
82
+ seq: number;
83
+ ack: () => void;
84
+ nak: (millis?: number) => void;
85
+ info: { stream: string; consumer: string; redeliveryCount: number; timestampNanos?: number };
86
+ }
87
+
88
+ const TEXT_DECODER = new TextDecoder();
89
+ const TEXT_ENCODER = new TextEncoder();
90
+
91
+ export class NATSPubSubAdapter implements PubSubAdapter {
92
+ readonly provider = "nats" as const;
93
+ private readonly config: NATSPubSubConfig;
94
+ private conn: NatsConnection | null = null;
95
+ private subscriptions: Map<string, NatsSubscription | { unsubscribe: () => Promise<void> | void }> = new Map();
96
+ private connected = false;
97
+
98
+ constructor(config?: Partial<NATSPubSubConfig>) {
99
+ this.config = {
100
+ servers: config?.servers ?? (process.env.NATS_SERVERS ?? "localhost:4222").split(",").map((s) => s.trim()),
101
+ token: config?.token ?? process.env.NATS_TOKEN,
102
+ user: config?.user ?? process.env.NATS_USER,
103
+ pass: config?.pass ?? process.env.NATS_PASS,
104
+ };
105
+ }
106
+
107
+ async connect(): Promise<void> {
108
+ if (this.connected) return;
109
+ try {
110
+ // biome-ignore lint/suspicious/noExplicitAny: nats is a runtime peer dep.
111
+ const nats: any = await import("nats");
112
+ this.conn = (await nats.connect({
113
+ servers: this.config.servers,
114
+ token: this.config.token,
115
+ user: this.config.user,
116
+ pass: this.config.pass,
117
+ })) as NatsConnection;
118
+ this.connected = true;
119
+ } catch (err) {
120
+ throw new Error(
121
+ `[blok][pubsub-nats] connect failed: ${(err as Error).message}. Install nats as a peer dependency: bun add nats`,
122
+ );
123
+ }
124
+ }
125
+
126
+ async disconnect(): Promise<void> {
127
+ if (!this.connected) return;
128
+ for (const sub of this.subscriptions.values()) {
129
+ try {
130
+ const result = sub.unsubscribe();
131
+ if (result instanceof Promise) await result;
132
+ } catch {
133
+ /* ignore */
134
+ }
135
+ }
136
+ this.subscriptions.clear();
137
+ try {
138
+ await this.conn?.drain();
139
+ } catch {
140
+ /* ignore */
141
+ }
142
+ this.conn = null;
143
+ this.connected = false;
144
+ }
145
+
146
+ async subscribe(config: PubSubTriggerOpts, handler: (message: PubSubMessage) => Promise<void>): Promise<void> {
147
+ if (!this.connected || !this.conn) throw new Error("[blok][pubsub-nats] not connected. Call connect() first.");
148
+ const subKey = `${config.topic}#${config.consumerGroup ?? ""}`;
149
+
150
+ if (config.durable === true) {
151
+ // JetStream durable subscription — survives restarts.
152
+ if (!this.conn.jetstream) {
153
+ throw new Error("[blok][pubsub-nats] durable subscriptions require JetStream support in the nats client");
154
+ }
155
+ const jsm = await this.conn.jetstreamManager?.();
156
+ if (jsm) {
157
+ // Auto-create a stream covering the subject if one doesn't
158
+ // exist. Production deployments should pre-provision via
159
+ // `nats stream add` to control retention.
160
+ try {
161
+ await jsm.streams.add({
162
+ name: `blok-${(config.topic ?? "").replace(/[^a-zA-Z0-9_]/g, "_")}`,
163
+ subjects: [config.topic],
164
+ });
165
+ } catch {
166
+ /* stream already exists — ignore */
167
+ }
168
+ }
169
+ const js = this.conn.jetstream();
170
+ const startSeq =
171
+ typeof config.startFrom === "object" && config.startFrom && "seq" in config.startFrom
172
+ ? config.startFrom.seq
173
+ : undefined;
174
+ const deliverPolicy =
175
+ config.startFrom === "earliest"
176
+ ? "all"
177
+ : config.startFrom === "latest"
178
+ ? "new"
179
+ : startSeq !== undefined
180
+ ? "by_start_sequence"
181
+ : "all";
182
+ const sub = await js.subscribe(config.topic, {
183
+ config: {
184
+ durable_name:
185
+ config.consumerGroup ??
186
+ `blok-${(config.subscription ?? config.topic ?? "default").replace(/[^a-zA-Z0-9_]/g, "_")}`,
187
+ deliver_policy: deliverPolicy,
188
+ opt_start_seq: startSeq,
189
+ ack_policy: config.ack === false ? "none" : "explicit",
190
+ },
191
+ });
192
+ this.subscriptions.set(subKey, { unsubscribe: () => sub.unsubscribe() });
193
+ // Drive the async iterator in a background loop.
194
+ void (async () => {
195
+ try {
196
+ for await (const msg of sub as unknown as AsyncIterable<NatsJsMsg>) {
197
+ await this.dispatchJsMessage(msg, config, handler);
198
+ }
199
+ } catch (err) {
200
+ // Subscription closed or connection lost — let the trigger
201
+ // re-listen via HMR/reconnect logic. Log for visibility.
202
+ console.error(`[blok][pubsub-nats] subscription error: ${(err as Error).message}`);
203
+ }
204
+ })();
205
+ return;
206
+ }
207
+
208
+ // Core NATS subscription — fire-and-forget, with optional queue
209
+ // group for competing-consumer semantics.
210
+ const sub = this.conn.subscribe(config.topic, {
211
+ queue: config.consumerGroup,
212
+ callback: (err, msg) => {
213
+ if (err) {
214
+ console.error(`[blok][pubsub-nats] subscribe error: ${err.message}`);
215
+ return;
216
+ }
217
+ void this.dispatchCoreMessage(msg, config, handler);
218
+ },
219
+ });
220
+ this.subscriptions.set(subKey, sub);
221
+ }
222
+
223
+ private async dispatchJsMessage(
224
+ msg: NatsJsMsg,
225
+ config: PubSubTriggerOpts,
226
+ handler: (message: PubSubMessage) => Promise<void>,
227
+ ): Promise<void> {
228
+ const text = TEXT_DECODER.decode(msg.data);
229
+ let body: unknown = text;
230
+ try {
231
+ body = text.length > 0 ? JSON.parse(text) : null;
232
+ } catch {
233
+ /* leave as text */
234
+ }
235
+ const message: PubSubMessage = {
236
+ id: `${msg.info.stream}:${msg.seq}`,
237
+ body,
238
+ attributes: { subject: msg.subject },
239
+ raw: msg,
240
+ topic: msg.subject,
241
+ subscription: msg.info.consumer,
242
+ publishTime: msg.info.timestampNanos ? new Date(msg.info.timestampNanos / 1e6) : undefined,
243
+ ack: async () => {
244
+ msg.ack();
245
+ },
246
+ nack: async () => {
247
+ msg.nak();
248
+ },
249
+ };
250
+ try {
251
+ await handler(message);
252
+ if (config.ack !== false) msg.ack();
253
+ } catch {
254
+ msg.nak();
255
+ }
256
+ }
257
+
258
+ private async dispatchCoreMessage(
259
+ msg: NatsMsg,
260
+ _config: PubSubTriggerOpts,
261
+ handler: (message: PubSubMessage) => Promise<void>,
262
+ ): Promise<void> {
263
+ const text = TEXT_DECODER.decode(msg.data);
264
+ let body: unknown = text;
265
+ try {
266
+ body = text.length > 0 ? JSON.parse(text) : null;
267
+ } catch {
268
+ /* leave as text */
269
+ }
270
+ const message: PubSubMessage = {
271
+ id: `${msg.subject}:${msg.sid}:${uuid()}`,
272
+ body,
273
+ attributes: { subject: msg.subject },
274
+ raw: msg,
275
+ topic: msg.subject,
276
+ ack: async () => {
277
+ /* core NATS has no explicit ack */
278
+ },
279
+ nack: async () => {
280
+ /* core NATS has no explicit nack */
281
+ },
282
+ };
283
+ try {
284
+ await handler(message);
285
+ } catch (err) {
286
+ console.error(`[blok][pubsub-nats] handler error: ${(err as Error).message}`);
287
+ }
288
+ }
289
+
290
+ async unsubscribe(subscription: string): Promise<void> {
291
+ const sub = this.subscriptions.get(subscription);
292
+ if (!sub) return;
293
+ try {
294
+ const result = sub.unsubscribe();
295
+ if (result instanceof Promise) await result;
296
+ } catch {
297
+ /* ignore */
298
+ }
299
+ this.subscriptions.delete(subscription);
300
+ }
301
+
302
+ async publish(topic: string, payload: unknown): Promise<void> {
303
+ if (!this.connected || !this.conn) throw new Error("[blok][pubsub-nats] not connected. Call connect() first.");
304
+ const body = typeof payload === "string" ? payload : JSON.stringify(payload);
305
+ const data = TEXT_ENCODER.encode(body);
306
+ // Use core NATS publish. When a durable subscriber has been
307
+ // installed for this subject, the subscribe() path created a
308
+ // JetStream stream with a subject filter that captures any
309
+ // publish to `topic` — so durable consumers see the message via
310
+ // the stream while core subscribers see it directly. The earlier
311
+ // "try js.publish first, fall back to core" logic caused
312
+ // **double-delivery**: js.publish to a subject covered by a
313
+ // stream goes to BOTH the stream and core subscribers, then the
314
+ // fallback would publish AGAIN if js.publish timed out (vs
315
+ // returning 503 fast). Sticking to core publish avoids the race.
316
+ this.conn.publish(topic, data);
317
+ }
318
+
319
+ isConnected(): boolean {
320
+ return this.connected;
321
+ }
322
+
323
+ async healthCheck(): Promise<boolean> {
324
+ return this.connected;
325
+ }
326
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * RedisStreamsPubSubAdapter — v0.7 PR 6 — Pub/Sub adapter backed by
3
+ * Redis Streams via `ioredis`.
4
+ *
5
+ * Pub/Sub vs Worker semantics: this adapter uses **distinct consumer
6
+ * groups per subscriber** so multiple subscribers each receive every
7
+ * message (fan-out). When `consumerGroup` is explicitly set on the
8
+ * workflow, all subscribers in the group compete (1 of N gets each).
9
+ *
10
+ * Replay cursors:
11
+ * - `"earliest"` / unset → `$` (only new messages by default).
12
+ * - `"earliest"` with explicit intent → `0` (replay full stream).
13
+ * - `{seq: N}` → resume from stream id `N-0`.
14
+ *
15
+ * Requires `ioredis` as a peer dependency.
16
+ *
17
+ * Environment variables:
18
+ * - `REDIS_HOST` (default `localhost`).
19
+ * - `REDIS_PORT` (default `6379`).
20
+ * - `REDIS_PASSWORD`.
21
+ * - `REDIS_DB`.
22
+ */
23
+
24
+ import type { PubSubTriggerOpts } from "@blokjs/helper";
25
+ import { v4 as uuid } from "uuid";
26
+ import type { PubSubAdapter, PubSubMessage } from "../PubSubTrigger";
27
+
28
+ export interface RedisStreamsPubSubConfig {
29
+ host: string;
30
+ port: number;
31
+ password?: string;
32
+ db?: number;
33
+ blockMs: number;
34
+ count: number;
35
+ }
36
+
37
+ interface RedisClient {
38
+ xadd(stream: string, ...args: string[]): Promise<string>;
39
+ xreadgroup(...args: string[]): Promise<Array<[string, Array<[string, string[]]>]> | null>;
40
+ xgroup(...args: string[]): Promise<string>;
41
+ xack(stream: string, group: string, ...ids: string[]): Promise<number>;
42
+ ping(): Promise<string>;
43
+ quit(): Promise<string>;
44
+ }
45
+
46
+ interface ActiveSubscription {
47
+ stop: () => void;
48
+ }
49
+
50
+ export class RedisStreamsPubSubAdapter implements PubSubAdapter {
51
+ readonly provider = "redis-streams" as const;
52
+ private readonly config: RedisStreamsPubSubConfig;
53
+ private client: RedisClient | null = null;
54
+ private subscriptions: Map<string, ActiveSubscription> = new Map();
55
+ private connected = false;
56
+ private consumerName = `blok-pubsub-${uuid().slice(0, 8)}`;
57
+
58
+ constructor(config?: Partial<RedisStreamsPubSubConfig>) {
59
+ this.config = {
60
+ host: config?.host ?? process.env.REDIS_HOST ?? "localhost",
61
+ port: config?.port ?? Number.parseInt(process.env.REDIS_PORT ?? "6379", 10),
62
+ password: config?.password ?? process.env.REDIS_PASSWORD,
63
+ db: config?.db ?? Number.parseInt(process.env.REDIS_DB ?? "0", 10),
64
+ blockMs: config?.blockMs ?? 5000,
65
+ count: config?.count ?? 10,
66
+ };
67
+ }
68
+
69
+ async connect(): Promise<void> {
70
+ if (this.connected) return;
71
+ try {
72
+ // biome-ignore lint/suspicious/noExplicitAny: ioredis is a runtime peer dep.
73
+ const ioredis: any = await import("ioredis");
74
+ const IORedis = ioredis.default ?? ioredis.Redis ?? ioredis;
75
+ this.client = new IORedis({
76
+ host: this.config.host,
77
+ port: this.config.port,
78
+ password: this.config.password,
79
+ db: this.config.db,
80
+ }) as RedisClient;
81
+ await this.client.ping();
82
+ this.connected = true;
83
+ } catch (err) {
84
+ throw new Error(
85
+ `[blok][pubsub-redis] connect failed: ${(err as Error).message}. Install ioredis as a peer dependency: bun add ioredis`,
86
+ );
87
+ }
88
+ }
89
+
90
+ async disconnect(): Promise<void> {
91
+ if (!this.connected) return;
92
+ for (const sub of this.subscriptions.values()) sub.stop();
93
+ this.subscriptions.clear();
94
+ try {
95
+ await this.client?.quit();
96
+ } catch {
97
+ /* ignore */
98
+ }
99
+ this.client = null;
100
+ this.connected = false;
101
+ }
102
+
103
+ async subscribe(config: PubSubTriggerOpts, handler: (message: PubSubMessage) => Promise<void>): Promise<void> {
104
+ if (!this.connected || !this.client) throw new Error("[blok][pubsub-redis] not connected. Call connect() first.");
105
+ const client = this.client;
106
+ const stream = config.topic;
107
+ // Fan-out: each subscriber gets its own group (unique per instance).
108
+ // Competing-consumer: explicit `consumerGroup` makes all subscribers
109
+ // share work.
110
+ const group = config.consumerGroup ?? `blok-fanout-${this.consumerName}-${stream.replace(/[^a-zA-Z0-9_]/g, "_")}`;
111
+ const startId =
112
+ config.startFrom === "earliest"
113
+ ? "0"
114
+ : config.startFrom === "latest" || config.startFrom === undefined
115
+ ? "$"
116
+ : typeof config.startFrom === "object" && "seq" in config.startFrom
117
+ ? `${config.startFrom.seq}-0`
118
+ : "$";
119
+
120
+ try {
121
+ await client.xgroup("CREATE", stream, group, startId, "MKSTREAM");
122
+ } catch (err) {
123
+ if (!/BUSYGROUP/i.test((err as Error).message)) throw err;
124
+ }
125
+
126
+ let stopped = false;
127
+ const sub: ActiveSubscription = {
128
+ stop: () => {
129
+ stopped = true;
130
+ },
131
+ };
132
+ this.subscriptions.set(`${stream}#${group}`, sub);
133
+
134
+ void (async () => {
135
+ while (!stopped) {
136
+ let entries: Array<[string, Array<[string, string[]]>]> | null = null;
137
+ try {
138
+ entries = await client.xreadgroup(
139
+ "GROUP",
140
+ group,
141
+ this.consumerName,
142
+ "COUNT",
143
+ String(this.config.count),
144
+ "BLOCK",
145
+ String(this.config.blockMs),
146
+ "STREAMS",
147
+ stream,
148
+ ">",
149
+ );
150
+ } catch {
151
+ await new Promise((r) => setTimeout(r, 1000));
152
+ continue;
153
+ }
154
+ if (!entries) continue;
155
+ for (const [, msgs] of entries) {
156
+ for (const [id, fields] of msgs) {
157
+ if (stopped) break;
158
+ const payload = this.fieldsToObject(fields);
159
+ const dataString = typeof payload.data === "string" ? payload.data : "";
160
+ let body: unknown;
161
+ try {
162
+ body = dataString.length > 0 ? JSON.parse(dataString) : null;
163
+ } catch {
164
+ body = dataString;
165
+ }
166
+ const message: PubSubMessage = {
167
+ id,
168
+ body,
169
+ attributes: payload,
170
+ raw: { id, fields },
171
+ topic: stream,
172
+ subscription: group,
173
+ publishTime: new Date(Number.parseInt(id.split("-")[0] ?? String(Date.now()), 10)),
174
+ ack: async () => {
175
+ await client.xack(stream, group, id);
176
+ },
177
+ nack: async () => {
178
+ /* leave unacked — picked up by XAUTOCLAIM */
179
+ },
180
+ };
181
+ try {
182
+ await handler(message);
183
+ if (config.ack !== false) await client.xack(stream, group, id);
184
+ } catch {
185
+ // Leave unacked — pending entries are visible in XPENDING.
186
+ }
187
+ }
188
+ }
189
+ }
190
+ })();
191
+ }
192
+
193
+ private fieldsToObject(fields: string[]): Record<string, string> {
194
+ const out: Record<string, string> = {};
195
+ for (let i = 0; i < fields.length; i += 2) out[fields[i]] = fields[i + 1];
196
+ return out;
197
+ }
198
+
199
+ async unsubscribe(subscription: string): Promise<void> {
200
+ const sub = this.subscriptions.get(subscription);
201
+ if (!sub) return;
202
+ sub.stop();
203
+ this.subscriptions.delete(subscription);
204
+ }
205
+
206
+ async publish(topic: string, payload: unknown): Promise<void> {
207
+ if (!this.connected || !this.client) throw new Error("[blok][pubsub-redis] not connected. Call connect() first.");
208
+ const body = typeof payload === "string" ? payload : JSON.stringify(payload);
209
+ await this.client.xadd(topic, "*", "data", body);
210
+ }
211
+
212
+ isConnected(): boolean {
213
+ return this.connected;
214
+ }
215
+
216
+ async healthCheck(): Promise<boolean> {
217
+ if (!this.connected || !this.client) return false;
218
+ try {
219
+ const pong = await this.client.ping();
220
+ return pong === "PONG";
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Pub/Sub adapter factory unit tests — v0.7 PR 6.
3
+ *
4
+ * Mirrors the worker factory tests (PR 5) — provider resolution
5
+ * order, constructor lookup, pool sharing, reset utility.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
9
+
10
+ import { _resetAdapterPoolForTests, createPubSubAdapter, getOrCreateAdapter, resolveProvider } from "./factory";
11
+
12
+ describe("pubsub adapter factory — v0.7 PR 6", () => {
13
+ beforeEach(() => {
14
+ _resetAdapterPoolForTests();
15
+ process.env.BLOK_PUBSUB_ADAPTER = undefined;
16
+ });
17
+
18
+ afterEach(() => {
19
+ _resetAdapterPoolForTests();
20
+ process.env.BLOK_PUBSUB_ADAPTER = undefined;
21
+ });
22
+
23
+ describe("resolveProvider()", () => {
24
+ it("returns the explicit provider when set", () => {
25
+ expect(resolveProvider("kafka")).toBe("kafka");
26
+ });
27
+
28
+ it("falls back to BLOK_PUBSUB_ADAPTER env var", () => {
29
+ process.env.BLOK_PUBSUB_ADAPTER = "redis-streams";
30
+ expect(resolveProvider(undefined)).toBe("redis-streams");
31
+ });
32
+
33
+ it("falls back to nats when neither is set", () => {
34
+ expect(resolveProvider(undefined)).toBe("nats");
35
+ });
36
+
37
+ it("ignores invalid env values and falls back to nats", () => {
38
+ process.env.BLOK_PUBSUB_ADAPTER = "not-a-provider";
39
+ expect(resolveProvider(undefined)).toBe("nats");
40
+ });
41
+
42
+ it("explicit provider wins over the env var", () => {
43
+ process.env.BLOK_PUBSUB_ADAPTER = "gcp";
44
+ expect(resolveProvider("aws")).toBe("aws");
45
+ });
46
+ });
47
+
48
+ describe("createPubSubAdapter()", () => {
49
+ it("returns the correct provider name for each built-in", () => {
50
+ expect(createPubSubAdapter("nats").provider).toBe("nats");
51
+ expect(createPubSubAdapter("redis-streams").provider).toBe("redis-streams");
52
+ expect(createPubSubAdapter("kafka").provider).toBe("kafka");
53
+ expect(createPubSubAdapter("gcp").provider).toBe("gcp");
54
+ expect(createPubSubAdapter("aws").provider).toBe("aws");
55
+ expect(createPubSubAdapter("azure").provider).toBe("azure");
56
+ });
57
+
58
+ it("each call returns a fresh instance", () => {
59
+ const a = createPubSubAdapter("nats");
60
+ const b = createPubSubAdapter("nats");
61
+ expect(a).not.toBe(b);
62
+ });
63
+ });
64
+
65
+ describe("getOrCreateAdapter()", () => {
66
+ it("returns the same instance on repeated calls for the same provider", () => {
67
+ const a = getOrCreateAdapter("nats");
68
+ const b = getOrCreateAdapter("nats");
69
+ expect(a).toBe(b);
70
+ });
71
+
72
+ it("returns different instances for different providers", () => {
73
+ const nats = getOrCreateAdapter("nats");
74
+ const kafka = getOrCreateAdapter("kafka");
75
+ expect(nats).not.toBe(kafka);
76
+ expect(nats.provider).toBe("nats");
77
+ expect(kafka.provider).toBe("kafka");
78
+ });
79
+
80
+ it("_resetAdapterPoolForTests() drops cached instances", () => {
81
+ const first = getOrCreateAdapter("nats");
82
+ _resetAdapterPoolForTests();
83
+ const second = getOrCreateAdapter("nats");
84
+ expect(first).not.toBe(second);
85
+ });
86
+ });
87
+ });