@pikku/queue-bullmq 0.12.2 → 0.12.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,77 @@
1
+ ## 0.12.4
2
+
3
+ ### Patch Changes
4
+
5
+ - 41ce2cb: Upgrade to TypeScript 6 and raise the minimum Node.js version to 22.
6
+
7
+ All packages now build against `typescript@^6.0.3` and declare `engines.node >= 22`. Internal tooling (`ts-json-schema-generator`, `zod-to-ts`) was bumped to TypeScript 6-compatible releases.
8
+
9
+ - Updated dependencies [41ce2cb]
10
+ - @pikku/core@0.12.44
11
+
12
+ ## 0.12.3
13
+
14
+ ### Patch Changes
15
+
16
+ - 92cd5b1: feat(workflow): workflow-owned step retries + stable invocationId
17
+
18
+ The workflow — not the queue — now owns step retry policy, and each step
19
+ invocation gets a stable idempotency key.
20
+ - **Default `retries: 5` with exponential backoff.** A step with no `retries`
21
+ previously inherited the queue's bare default (e.g. pg-boss `retry_limit 2`,
22
+ no backoff) so retries fired instantly and couldn't outlast a transient
23
+ outage. Retries now default to 5 with backoff, resolved at the workflow layer.
24
+ - **`retries: 0` is honored.** Dispatch previously passed `undefined` options
25
+ for `retries: 0`, letting the queue re-run a non-idempotent step up to its own
26
+ default. The resolved policy now always sets `attempts` (`retries: 0` →
27
+ `attempts: 1`), so the queue never second-guesses the workflow. The persisted
28
+ step retries and the dispatched `attempts` are resolved together so
29
+ "retries exhausted" and "no more redeliveries" are the same event.
30
+ - **`workflowStep.invocationId`** — a deterministic, dependency-free
31
+ `uuidv5(runId:stepName)` handed to every step. Unlike `stepId` (minted per
32
+ attempt), it is identical across retries, so a step can dedupe on it
33
+ (`ON CONFLICT (invocationId)`, Stripe idempotency keys, etc.).
34
+ - **queue-bullmq**: `mapPikkuJobToBull` now maps `backoff` (previously dropped,
35
+ so a step's backoff silently never applied on Redis), and `registerQueues`
36
+ throws a clear error when no logger is available (matching queue-pg-boss).
37
+ - **Dispatch failures are recoverable, not fatal.** A step is now marked
38
+ `scheduled` only _after_ it is successfully handed to its transport (queue or
39
+ scheduler) — a failed hand-off leaves it `pending` so a replay re-dispatches
40
+ it, instead of stranding it in `scheduled` (replay would pause forever on a
41
+ job that was never enqueued). A transport outage (e.g. pg-boss momentarily
42
+ down) is surfaced as a new `WorkflowDispatchException`, which the orchestrator
43
+ treats as transient: the run is left running and the orchestrator job is
44
+ rethrown for redelivery (it replays idempotently from the snapshot) rather
45
+ than the whole run being marked `failed`. The orchestrator job now also
46
+ carries its own retry policy, so this holds even when the orchestrator queue
47
+ is configured `retry_limit 0`. A genuine step error still fails the run.
48
+ - **Same step name can be invoked multiple times in one run.** Step rows are now
49
+ keyed per _invocation_: the Nth reach of a step name in a replay resolves to a
50
+ physical key (`name` for the first, `name#N` for repeats), so a literal
51
+ duplicate name no longer clobbers the earlier step's state. The first reach
52
+ keeps the bare name, so existing rows, graph-node matching and `invocationId`s
53
+ are unchanged. Ordinals are derived deterministically from DSL execution order
54
+ and reset each replay.
55
+ - **Step provenance (`fromStepName`) + graph cycles.** Every step now records
56
+ the predecessor it was scheduled from (`fromStepName`; entry steps have none),
57
+ persisted on the step row across all stores (in-memory, kysely, redis,
58
+ mongodb, cloudflare DO) and carried in the queued payload. The DSL wire
59
+ exposes the derived `fromInvocationId` (`uuidv5(runId:fromStepName)`) so
60
+ consumers get the stable predecessor key without a second persisted id —
61
+ `fromStepName` is the source of truth (it is replay-deterministic; `stepId`,
62
+ minted per row, is not). This makes the walked path reconstructable even when
63
+ a node is reached more than once: in `a → b → a → c` the second `a` is a
64
+ distinct ordinal instance (`a#1`) whose `fromStepName` is `b`.
65
+ The graph runner now supports **cycles**: a forward edge into an
66
+ already-started node still collapses to a single run (joins/diamonds are
67
+ unchanged), but a _back-edge_ — one whose target can reach its source — fires
68
+ a fresh ordinal instance, so a node can loop back to itself. Termination is
69
+ the graph's responsibility (branch routing must converge); the engine enforces
70
+ no visit cap.
71
+
72
+ - Updated dependencies [92cd5b1]
73
+ - @pikku/core@0.12.38
74
+
1
75
  ## 0.12.0
2
76
 
3
77
  ## 0.12.2
@@ -11,6 +11,14 @@ export const mapPikkuJobToBull = (options) => {
11
11
  if (options?.attempts !== undefined) {
12
12
  bullOptions.attempts = options.attempts;
13
13
  }
14
+ // Map retry backoff. BullMQ accepts `{ type, delay }`; our `delay` is in ms,
15
+ // which is what BullMQ expects too (unlike pg-boss, which uses seconds).
16
+ if (options?.backoff !== undefined) {
17
+ bullOptions.backoff =
18
+ typeof options.backoff === 'string'
19
+ ? { type: options.backoff }
20
+ : { type: options.backoff.type, delay: options.backoff.delay };
21
+ }
14
22
  if (options?.removeOnComplete !== undefined) {
15
23
  bullOptions.removeOnComplete = options.removeOnComplete;
16
24
  }
@@ -107,7 +107,10 @@ export class BullQueueWorkers {
107
107
  * Scan state and register all compatible processors
108
108
  */
109
109
  async registerQueues() {
110
- const logger = pikkuState(null, 'package', 'singletonServices').logger;
110
+ const logger = pikkuState(null, 'package', 'singletonServices')?.logger;
111
+ if (!logger) {
112
+ throw new Error('Logger is required for registerQueues — pass it explicitly or ensure singleton services are initialized first');
113
+ }
111
114
  return await registerQueueWorkers(this.configMappings, logger, async (queueName, processor) => {
112
115
  const worker = new Worker(processor.name, async (job) => {
113
116
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/queue-bullmq",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -8,18 +8,23 @@
8
8
  "type": "module",
9
9
  "scripts": {
10
10
  "tsc": "tsc",
11
+ "test": "bash run-tests.sh",
11
12
  "ncu": "npx npm-check-updates",
12
13
  "build": "tsc -b",
13
- "release": "npm run build && npm test"
14
+ "release": "npm run build && npm test",
15
+ "prepublishOnly": "yarn build"
14
16
  },
15
17
  "peerDependencies": {
16
- "@pikku/core": "^0.12.2"
18
+ "@pikku/core": "^0.12.44"
17
19
  },
18
20
  "dependencies": {
19
21
  "bullmq": "^5.70.1",
20
22
  "ioredis": "^5.10.0"
21
23
  },
22
24
  "devDependencies": {
23
- "typescript": "^5.9"
25
+ "typescript": "^6.0.3"
26
+ },
27
+ "exports": {
28
+ ".": "./dist/index.js"
24
29
  }
25
30
  }
package/run-tests.sh CHANGED
@@ -1,3 +1,5 @@
1
1
  #!/bin/bash
2
2
  cd "$(dirname "$0")"
3
- exec node --test --experimental-test-coverage --loader=tsx/esm dist/**/*.test.js
3
+ files=($(find src -type f -name "*.test.ts"))
4
+ if [ ${#files[@]} -eq 0 ]; then echo "No test files found"; exit 0; fi
5
+ node --import tsx --test "$@" "${files[@]}"
@@ -0,0 +1,36 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { mapPikkuJobToBull } from './bull-queue-service.js'
5
+
6
+ // Same contract as the pg-boss adapter: the workflow's resolved retry policy
7
+ // (attempts + backoff) must reach BullMQ. backoff mapping in particular was
8
+ // previously dropped, so a step's backoff silently never applied on Redis.
9
+ describe('mapPikkuJobToBull — retry mapping', () => {
10
+ test('attempts passes through unchanged (BullMQ uses attempts directly)', () => {
11
+ assert.equal(mapPikkuJobToBull({ attempts: 6 }).attempts, 6)
12
+ assert.equal(mapPikkuJobToBull({ attempts: 1 }).attempts, 1)
13
+ })
14
+
15
+ test("string backoff 'exponential' → { type: 'exponential' }", () => {
16
+ assert.deepEqual(mapPikkuJobToBull({ attempts: 6, backoff: 'exponential' }).backoff, {
17
+ type: 'exponential',
18
+ })
19
+ })
20
+
21
+ test('object backoff → { type, delay } (delay stays in ms)', () => {
22
+ assert.deepEqual(
23
+ mapPikkuJobToBull({ attempts: 4, backoff: { type: 'fixed', delay: 250 } })
24
+ .backoff,
25
+ { type: 'fixed', delay: 250 }
26
+ )
27
+ })
28
+
29
+ test('no backoff → backoff left unset', () => {
30
+ assert.equal(mapPikkuJobToBull({ attempts: 6 }).backoff, undefined)
31
+ })
32
+
33
+ test('no options → empty job options', () => {
34
+ assert.deepEqual(mapPikkuJobToBull(undefined), {})
35
+ })
36
+ })
@@ -19,6 +19,15 @@ export const mapPikkuJobToBull = (options?: JobOptions): JobsOptions => {
19
19
  bullOptions.attempts = options.attempts
20
20
  }
21
21
 
22
+ // Map retry backoff. BullMQ accepts `{ type, delay }`; our `delay` is in ms,
23
+ // which is what BullMQ expects too (unlike pg-boss, which uses seconds).
24
+ if (options?.backoff !== undefined) {
25
+ bullOptions.backoff =
26
+ typeof options.backoff === 'string'
27
+ ? { type: options.backoff }
28
+ : { type: options.backoff.type, delay: options.backoff.delay }
29
+ }
30
+
22
31
  if (options?.removeOnComplete !== undefined) {
23
32
  bullOptions.removeOnComplete = options.removeOnComplete
24
33
  }
@@ -4,12 +4,12 @@ import assert from 'node:assert/strict'
4
4
  import { BullQueueWorkers } from './bull-queue-worker.js'
5
5
 
6
6
  describe('BullQueueWorkers', () => {
7
- test('requires setJobRunner before registerQueues', async () => {
7
+ test('requires a logger (explicit or from singleton services)', async () => {
8
8
  const workers = new BullQueueWorkers({})
9
9
 
10
10
  await assert.rejects(() => workers.registerQueues(), {
11
11
  message:
12
- 'BullQueueWorkers requires setJobRunner() before registerQueues()',
12
+ 'Logger is required for registerQueues — pass it explicitly or ensure singleton services are initialized first',
13
13
  })
14
14
  })
15
15
  })
@@ -141,7 +141,12 @@ export class BullQueueWorkers implements QueueWorkers {
141
141
  * Scan state and register all compatible processors
142
142
  */
143
143
  async registerQueues(): Promise<Record<string, ConfigValidationResult[]>> {
144
- const logger = pikkuState(null, 'package', 'singletonServices')!.logger
144
+ const logger = pikkuState(null, 'package', 'singletonServices')?.logger
145
+ if (!logger) {
146
+ throw new Error(
147
+ 'Logger is required for registerQueues — pass it explicitly or ensure singleton services are initialized first'
148
+ )
149
+ }
145
150
 
146
151
  return await registerQueueWorkers(
147
152
  this.configMappings,
@@ -1 +1 @@
1
- {"root":["./src/bull-queue-service.ts","./src/bull-queue-worker.ts","./src/bull-scheduler-service.ts","./src/bull-service-factory.ts","./src/index.ts","./src/utils.ts"],"version":"5.9.3"}
1
+ {"root":["./src/bull-queue-service.ts","./src/bull-queue-worker.ts","./src/bull-scheduler-service.ts","./src/bull-service-factory.ts","./src/index.ts","./src/utils.ts"],"version":"6.0.3"}