@blokjs/trigger-worker 0.4.0 → 0.6.2

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 (37) hide show
  1. package/__tests__/integration/nats-adapter.real-nats.test.ts +116 -0
  2. package/__tests__/integration/pgboss-adapter.real-pg.test.ts +164 -0
  3. package/__tests__/integration/rabbitmq-adapter.real-rabbitmq.test.ts +179 -0
  4. package/__tests__/integration/sqs-adapter.real-sqs.test.ts +228 -0
  5. package/dist/WorkerTrigger.d.ts +37 -1
  6. package/dist/WorkerTrigger.js +142 -21
  7. package/dist/adapters/InMemoryAdapter.js +10 -5
  8. package/dist/adapters/KafkaAdapter.d.ts +62 -0
  9. package/dist/adapters/KafkaAdapter.js +236 -0
  10. package/dist/adapters/NATSAdapter.js +3 -3
  11. package/dist/adapters/PgBossAdapter.d.ts +56 -0
  12. package/dist/adapters/PgBossAdapter.js +251 -0
  13. package/dist/adapters/RabbitMQAdapter.d.ts +51 -0
  14. package/dist/adapters/RabbitMQAdapter.js +241 -0
  15. package/dist/adapters/RedisStreamsAdapter.d.ts +64 -0
  16. package/dist/adapters/RedisStreamsAdapter.js +240 -0
  17. package/dist/adapters/SQSAdapter.d.ts +61 -0
  18. package/dist/adapters/SQSAdapter.js +269 -0
  19. package/dist/adapters/factory.d.ts +34 -0
  20. package/dist/adapters/factory.js +103 -0
  21. package/dist/index.d.ts +21 -4
  22. package/dist/index.js +24 -4
  23. package/package.json +23 -5
  24. package/src/WorkerTrigger.ts +153 -22
  25. package/src/adapters/InMemoryAdapter.ts +9 -5
  26. package/src/adapters/KafkaAdapter.ts +277 -0
  27. package/src/adapters/NATSAdapter.ts +4 -2
  28. package/src/adapters/PgBossAdapter.ts +293 -0
  29. package/src/adapters/RabbitMQAdapter.ts +285 -0
  30. package/src/adapters/RedisStreamsAdapter.ts +286 -0
  31. package/src/adapters/SQSAdapter.ts +306 -0
  32. package/src/adapters/factory.test.ts +89 -0
  33. package/src/adapters/factory.ts +111 -0
  34. package/src/adapters/new-adapters.test.ts +130 -0
  35. package/src/index.ts +30 -4
  36. package/template/package.json +6 -6
  37. package/template/src/workflows/jobs/process-job.ts +37 -35
@@ -0,0 +1,111 @@
1
+ /**
2
+ * v0.7 PR 5 — adapter factory.
3
+ *
4
+ * Resolves a `provider` string to a concrete `WorkerAdapter` instance.
5
+ * Used by the `WorkerTrigger` (to pick the right adapter per workflow
6
+ * based on `trigger.worker.provider`) and by the `@blokjs/worker-publish`
7
+ * helper node (to enqueue jobs from any workflow without bundling all
8
+ * broker clients).
9
+ *
10
+ * Provider resolution order:
11
+ * 1. Explicit `provider` field on the workflow (highest priority).
12
+ * 2. `BLOK_WORKER_ADAPTER` env var (per Q7 resolution in the plan).
13
+ * 3. `"in-memory"` fallback (zero-infra default for dev/tests).
14
+ *
15
+ * Each adapter beyond `in-memory` lazy-imports its broker SDK on first
16
+ * use (BullMQ does this today). Workflows that don't use a given
17
+ * provider don't pay the install or import cost.
18
+ */
19
+
20
+ import type { WorkerProvider } from "@blokjs/helper";
21
+ import type { WorkerAdapter } from "../WorkerTrigger";
22
+ import { BullMQAdapter } from "./BullMQAdapter";
23
+ import { InMemoryAdapter } from "./InMemoryAdapter";
24
+ import { KafkaAdapter } from "./KafkaAdapter";
25
+ import { NATSWorkerAdapter } from "./NATSAdapter";
26
+ import { PgBossAdapter } from "./PgBossAdapter";
27
+ import { RabbitMQAdapter } from "./RabbitMQAdapter";
28
+ import { RedisStreamsAdapter } from "./RedisStreamsAdapter";
29
+ import { SQSAdapter } from "./SQSAdapter";
30
+
31
+ /**
32
+ * Resolve the effective provider for a workflow. The trigger's
33
+ * `provider` field always wins; otherwise fall back to the
34
+ * `BLOK_WORKER_ADAPTER` env var; otherwise `"in-memory"`.
35
+ */
36
+ export function resolveProvider(provider?: WorkerProvider): WorkerProvider {
37
+ if (provider) return provider;
38
+ const envValue = process.env.BLOK_WORKER_ADAPTER;
39
+ if (envValue && isWorkerProvider(envValue)) return envValue;
40
+ return "in-memory";
41
+ }
42
+
43
+ function isWorkerProvider(value: string): value is WorkerProvider {
44
+ return (
45
+ value === "in-memory" ||
46
+ value === "nats" ||
47
+ value === "bullmq" ||
48
+ value === "kafka" ||
49
+ value === "rabbitmq" ||
50
+ value === "sqs" ||
51
+ value === "redis" ||
52
+ value === "pg-boss"
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Construct an adapter for the named provider. Throws a clear error
58
+ * for unknown names — keeps the schema validation and runtime
59
+ * behaviour in sync (the Zod enum catches typos at workflow load).
60
+ */
61
+ export function createWorkerAdapter(provider: WorkerProvider): WorkerAdapter {
62
+ switch (provider) {
63
+ case "in-memory":
64
+ return new InMemoryAdapter();
65
+ case "nats":
66
+ return new NATSWorkerAdapter();
67
+ case "bullmq":
68
+ return new BullMQAdapter();
69
+ case "kafka":
70
+ return new KafkaAdapter();
71
+ case "rabbitmq":
72
+ return new RabbitMQAdapter();
73
+ case "sqs":
74
+ return new SQSAdapter();
75
+ case "redis":
76
+ return new RedisStreamsAdapter();
77
+ case "pg-boss":
78
+ return new PgBossAdapter();
79
+ default: {
80
+ const exhaustive: never = provider;
81
+ throw new Error(`[blok][worker] unknown provider "${exhaustive as string}". Check WorkerProviderSchema.`);
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Process-singleton adapter pool — one instance per provider. The
88
+ * trigger calls `getOrCreateAdapter("kafka")` once per workflow, and
89
+ * subsequent workflows on the same provider share the broker
90
+ * connection. Adapters are connected lazily — `getOrCreateAdapter`
91
+ * never connects on its own; the caller calls `adapter.connect()`.
92
+ *
93
+ * Reset via `_resetAdapterPoolForTests()` between vitest suites.
94
+ */
95
+ const pool: Map<WorkerProvider, WorkerAdapter> = new Map();
96
+
97
+ export function getOrCreateAdapter(provider: WorkerProvider): WorkerAdapter {
98
+ let adapter = pool.get(provider);
99
+ if (!adapter) {
100
+ adapter = createWorkerAdapter(provider);
101
+ pool.set(provider, adapter);
102
+ }
103
+ return adapter;
104
+ }
105
+
106
+ export function _resetAdapterPoolForTests(): void {
107
+ for (const adapter of pool.values()) {
108
+ void adapter.disconnect?.().catch(() => {});
109
+ }
110
+ pool.clear();
111
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Smoke tests for the v0.7 PR 5 adapters (Kafka, RabbitMQ, SQS, Redis
3
+ * Streams, pg-boss). Each adapter is exercised at the boundaries
4
+ * that don't require a live broker connection:
5
+ *
6
+ * - Constructor + provider name + initial connected state.
7
+ * - `disconnect()` is a no-op before connect.
8
+ *
9
+ * The peer-dep error paths and the broker round-trips need real
10
+ * brokers — those live in the docker-compose integration suite that
11
+ * we'll wire up as a follow-up (see PR 5 plan, "Out of scope:
12
+ * docker-compose CI").
13
+ */
14
+
15
+ import { describe, expect, it } from "vitest";
16
+
17
+ import { KafkaAdapter } from "./KafkaAdapter";
18
+ import { PgBossAdapter } from "./PgBossAdapter";
19
+ import { RabbitMQAdapter } from "./RabbitMQAdapter";
20
+ import { RedisStreamsAdapter } from "./RedisStreamsAdapter";
21
+ import { SQSAdapter } from "./SQSAdapter";
22
+
23
+ describe("KafkaAdapter — v0.7 PR 5", () => {
24
+ it("reports provider 'kafka'", () => {
25
+ expect(new KafkaAdapter().provider).toBe("kafka");
26
+ });
27
+
28
+ it("is not connected before connect()", () => {
29
+ expect(new KafkaAdapter().isConnected()).toBe(false);
30
+ });
31
+
32
+ it("disconnect() before connect is a no-op", async () => {
33
+ await expect(new KafkaAdapter().disconnect()).resolves.toBeUndefined();
34
+ });
35
+
36
+ it("reads broker list from KAFKA_BROKERS env var", () => {
37
+ process.env.KAFKA_BROKERS = "broker-a:9092,broker-b:9092";
38
+ const adapter = new KafkaAdapter();
39
+ expect((adapter as unknown as { config: { brokers: string[] } }).config.brokers).toEqual([
40
+ "broker-a:9092",
41
+ "broker-b:9092",
42
+ ]);
43
+ process.env.KAFKA_BROKERS = undefined;
44
+ });
45
+ });
46
+
47
+ describe("RabbitMQAdapter — v0.7 PR 5", () => {
48
+ it("reports provider 'rabbitmq'", () => {
49
+ expect(new RabbitMQAdapter().provider).toBe("rabbitmq");
50
+ });
51
+
52
+ it("is not connected before connect()", () => {
53
+ expect(new RabbitMQAdapter().isConnected()).toBe(false);
54
+ });
55
+
56
+ it("disconnect() before connect is a no-op", async () => {
57
+ await expect(new RabbitMQAdapter().disconnect()).resolves.toBeUndefined();
58
+ });
59
+
60
+ it("reads AMQP_URL from env var", () => {
61
+ process.env.AMQP_URL = "amqp://prod.example:5672";
62
+ const adapter = new RabbitMQAdapter();
63
+ expect((adapter as unknown as { config: { url: string } }).config.url).toBe("amqp://prod.example:5672");
64
+ process.env.AMQP_URL = undefined;
65
+ });
66
+ });
67
+
68
+ describe("SQSAdapter — v0.7 PR 5", () => {
69
+ it("reports provider 'sqs'", () => {
70
+ expect(new SQSAdapter().provider).toBe("sqs");
71
+ });
72
+
73
+ it("is not connected before connect()", () => {
74
+ expect(new SQSAdapter().isConnected()).toBe(false);
75
+ });
76
+
77
+ it("disconnect() before connect is a no-op", async () => {
78
+ await expect(new SQSAdapter().disconnect()).resolves.toBeUndefined();
79
+ });
80
+
81
+ it("honors the explicit region override", () => {
82
+ const adapter = new SQSAdapter({ region: "eu-west-2" });
83
+ expect((adapter as unknown as { config: { region: string } }).config.region).toBe("eu-west-2");
84
+ });
85
+ });
86
+
87
+ describe("RedisStreamsAdapter — v0.7 PR 5", () => {
88
+ it("reports provider 'redis'", () => {
89
+ expect(new RedisStreamsAdapter().provider).toBe("redis");
90
+ });
91
+
92
+ it("is not connected before connect()", () => {
93
+ expect(new RedisStreamsAdapter().isConnected()).toBe(false);
94
+ });
95
+
96
+ it("disconnect() before connect is a no-op", async () => {
97
+ await expect(new RedisStreamsAdapter().disconnect()).resolves.toBeUndefined();
98
+ });
99
+
100
+ it("generates a unique consumer name per instance", () => {
101
+ const a = new RedisStreamsAdapter();
102
+ const b = new RedisStreamsAdapter();
103
+ expect((a as unknown as { consumerName: string }).consumerName).not.toBe(
104
+ (b as unknown as { consumerName: string }).consumerName,
105
+ );
106
+ });
107
+ });
108
+
109
+ describe("PgBossAdapter — v0.7 PR 5", () => {
110
+ it("reports provider 'pg-boss'", () => {
111
+ expect(new PgBossAdapter().provider).toBe("pg-boss");
112
+ });
113
+
114
+ it("is not connected before connect()", () => {
115
+ expect(new PgBossAdapter().isConnected()).toBe(false);
116
+ });
117
+
118
+ it("disconnect() before connect is a no-op", async () => {
119
+ await expect(new PgBossAdapter().disconnect()).resolves.toBeUndefined();
120
+ });
121
+
122
+ it("connect() throws a clear peer-dep error when pg-boss is absent", async () => {
123
+ // pg-boss is the only adapter SDK NOT pre-installed in this monorepo,
124
+ // so the lazy-import path actually throws here. The other adapters'
125
+ // SDKs ARE installed (other workspaces use them) — their peer-dep
126
+ // error paths get exercised in the docker-compose integration suite.
127
+ const adapter = new PgBossAdapter();
128
+ await expect(adapter.connect()).rejects.toThrow(/pg-boss/);
129
+ });
130
+ });
package/src/index.ts CHANGED
@@ -10,9 +10,20 @@
10
10
  * - Delayed job scheduling
11
11
  * - Queue statistics and monitoring
12
12
  *
13
- * Adapters:
14
- * - BullMQ (Redis-backed, production)
15
- * - InMemory (development/testing)
13
+ * Adapters (v0.7+):
14
+ * - BullMQ Redis-backed, ops-style queues (`bullmq` peer dep)
15
+ * - InMemory development / tests (no peer deps)
16
+ * - NATS — JetStream durable streams (`nats` peer dep)
17
+ * - Kafka — high-throughput streaming (`kafkajs` peer dep)
18
+ * - RabbitMQ — reliable enterprise queues (`amqplib` peer dep)
19
+ * - SQS — AWS cloud queues (`@aws-sdk/client-sqs` peer dep)
20
+ * - Redis Streams — when Redis is already in stack (`ioredis` peer dep)
21
+ * - pg-boss — no extra infra (`pg-boss` peer dep)
22
+ *
23
+ * v0.7+ — pick the adapter per workflow via `trigger.worker.provider`.
24
+ * `BLOK_WORKER_ADAPTER` env var sets the default. Subclasses can still
25
+ * set `protected adapter` directly for back-compat with the pre-v0.7
26
+ * single-adapter pattern.
16
27
  *
17
28
  * @example BullMQ
18
29
  * ```typescript
@@ -62,7 +73,22 @@ export {
62
73
  // Adapters
63
74
  export { BullMQAdapter, type BullMQConfig } from "./adapters/BullMQAdapter";
64
75
  export { InMemoryAdapter } from "./adapters/InMemoryAdapter";
76
+ export { KafkaAdapter, type KafkaConfig } from "./adapters/KafkaAdapter";
65
77
  export { NATSWorkerAdapter, type NATSWorkerConfig } from "./adapters/NATSAdapter";
78
+ export { PgBossAdapter, type PgBossConfig } from "./adapters/PgBossAdapter";
79
+ export { RabbitMQAdapter, type RabbitMQConfig } from "./adapters/RabbitMQAdapter";
80
+ export { RedisStreamsAdapter, type RedisStreamsConfig } from "./adapters/RedisStreamsAdapter";
81
+ export { SQSAdapter, type SQSConfig } from "./adapters/SQSAdapter";
82
+
83
+ // v0.7 PR 5 — factory + pool. Used by WorkerTrigger and exposed for
84
+ // helper nodes (`@blokjs/worker-publish`) that need to enqueue jobs
85
+ // from any workflow without bundling all broker SDKs.
86
+ export {
87
+ _resetAdapterPoolForTests,
88
+ createWorkerAdapter,
89
+ getOrCreateAdapter,
90
+ resolveProvider,
91
+ } from "./adapters/factory";
66
92
 
67
93
  // Re-export types from helper for convenience
68
- export type { WorkerTriggerOpts } from "@blokjs/helper";
94
+ export type { WorkerProvider, WorkerTriggerOpts } from "@blokjs/helper";
@@ -25,12 +25,12 @@
25
25
  "vitest": "^4.0.18"
26
26
  },
27
27
  "dependencies": {
28
- "@blokjs/api-call": "^0.4.0",
29
- "@blokjs/helper": "^0.4.0",
30
- "@blokjs/if-else": "^0.4.0",
31
- "@blokjs/runner": "^0.4.0",
32
- "@blokjs/shared": "^0.4.0",
33
- "@blokjs/trigger-worker": "^0.4.0",
28
+ "@blokjs/api-call": "^0.6.2",
29
+ "@blokjs/helper": "^0.6.2",
30
+ "@blokjs/if-else": "^0.6.2",
31
+ "@blokjs/runner": "^0.6.2",
32
+ "@blokjs/shared": "^0.6.2",
33
+ "@blokjs/trigger-worker": "^0.6.2",
34
34
  "@opentelemetry/api": "^1.9.0",
35
35
  "@opentelemetry/exporter-prometheus": "^0.57.2",
36
36
  "@opentelemetry/resources": "^1.30.1",
@@ -1,45 +1,47 @@
1
- import { type Step, Workflow } from "@blokjs/helper";
1
+ import { workflow } from "@blokjs/helper";
2
2
 
3
3
  /**
4
- * Example Worker workflow - triggered when a job is received from the queue
4
+ * Example Worker workflow fires when a job is received from the queue.
5
5
  *
6
- * The job data is available in ctx.request:
7
- * - ctx.request.body: The job payload
8
- * - ctx.request.headers: Job headers/metadata
9
- * - ctx.request.params.queue: The queue name
10
- * - ctx.request.params.jobId: Unique job ID
11
- * - ctx.request.params.attempt: Current attempt number (0-based)
6
+ * The job payload + metadata land on ctx.request:
7
+ * - ctx.request.body the job payload as posted
8
+ * - ctx.request.headers job headers
9
+ * - ctx.request.params.queue queue name
10
+ * - ctx.request.params.jobId unique job ID
11
+ * - ctx.request.params.attempt retry attempt (0-based)
12
+ * - ctx.vars._worker_job — full job metadata
12
13
  *
13
- * Additional metadata is available in ctx.vars._worker_job:
14
- * - id: Unique job ID
15
- * - queue: Queue name
16
- * - attempts: Current attempt number
17
- * - maxRetries: Maximum retry count
18
- * - priority: Job priority (if set)
19
- * - createdAt: When the job was created (ISO string)
14
+ * v2 reliability knobs available on each step (uncomment to use):
15
+ * idempotencyKey: "$.req.params.jobId" — skip re-runs of the same job
16
+ * retry: { maxAttempts: 3 } — retry on transient failures
17
+ * maxDuration: "30s" — fail the step if it hangs
18
+ *
19
+ * Trigger-level reliability:
20
+ * concurrencyKey: "$.req.body.tenantId" — per-tenant fairness
21
+ * onLimit: "queue" — defer instead of reject
20
22
  */
21
- const step: Step = Workflow({
23
+ export default workflow({
22
24
  name: "Process Background Job",
23
25
  version: "1.0.0",
24
26
  description: "Handles incoming worker jobs from the queue",
25
- })
26
- .addTrigger("worker", {
27
- queue: "background-jobs",
28
- })
29
- .addStep({
30
- name: "process-job",
31
- node: "@blokjs/api-call",
32
- type: "module",
33
- inputs: {
34
- url: "https://httpbin.org/post",
35
- method: "POST",
36
- body: {
37
- job: "js/ctx.request.body",
38
- queue: "js/ctx.request.params.queue",
39
- jobId: "js/ctx.request.params.jobId",
40
- attempt: "js/ctx.request.params.attempt",
27
+ trigger: {
28
+ worker: { queue: "background-jobs" },
29
+ },
30
+ steps: [
31
+ {
32
+ id: "process-job",
33
+ use: "@blokjs/api-call",
34
+ type: "module",
35
+ inputs: {
36
+ url: "https://httpbin.org/post",
37
+ method: "POST",
38
+ body: {
39
+ job: "js/ctx.request.body",
40
+ queue: "js/ctx.request.params.queue",
41
+ jobId: "js/ctx.request.params.jobId",
42
+ attempt: "js/ctx.request.params.attempt",
43
+ },
41
44
  },
42
45
  },
43
- });
44
-
45
- export default step;
46
+ ],
47
+ });