@12-apps/jobs 1.4.0 → 1.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/jobs",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "type": "module",
5
5
  "description": "Generic background-job library: a typed job registry with retries, exponential backoff and cron schedules, behind a swappable driver port (BullMQ/Redis in production, inline execution in tests). Framework-free; knows nothing about the host app's domain, ORM or transport.",
6
6
  "exports": {
@@ -20,8 +20,8 @@
20
20
  "bullmq": "^5.81.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@12-apps/eslint-config": "^1.5.0",
24
- "@12-apps/typescript-config": "^1.5.0",
23
+ "@12-apps/eslint-config": "^1.5.2",
24
+ "@12-apps/typescript-config": "^1.5.2",
25
25
  "eslint": "^9.39.1",
26
26
  "eslint-plugin-test-flakiness": "^1.4.0",
27
27
  "typescript": "^5.9.2",
@@ -46,6 +46,14 @@
46
46
  "prisma",
47
47
  "*.js",
48
48
  "*.mjs",
49
- "*.md"
49
+ "*.md",
50
+ "!eslint.config.js",
51
+ "!**/__tests__/**",
52
+ "!**/tests/**",
53
+ "!**/*.test.*",
54
+ "!**/*.spec.*",
55
+ "!**/*.stories.*",
56
+ "!**/*.test-story.*",
57
+ "!**/test-helpers.*"
50
58
  ]
51
59
  }
package/eslint.config.js DELETED
@@ -1,22 +0,0 @@
1
- import { config as baseConfig } from "@12-apps/eslint-config/base";
2
- import testFlakiness from "eslint-plugin-test-flakiness";
3
-
4
- /**
5
- * The everyday DX lint for this package. Mirrors `packages/entitlements`:
6
- * `eslint-plugin-test-flakiness` is registered with every rule OFF so that
7
- * inline disable directives in test files RESOLVE here, while the rules
8
- * themselves stay enforced by the repo-root flakiness lane.
9
- */
10
-
11
- /** @type {import("eslint").Linter.Config[]} */
12
- export default [
13
- ...baseConfig,
14
- {
15
- files: ["**/__tests__/**", "**/*.test.ts"],
16
- plugins: { "test-flakiness": testFlakiness },
17
- linterOptions: { reportUnusedDisableDirectives: "off" },
18
- },
19
- {
20
- ignores: ["dist/**", "node_modules/**", "coverage/**"],
21
- },
22
- ];
@@ -1,101 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
2
-
3
- import { createInlineJobDriver } from "../../drivers/inline";
4
- import { clearJobs, defineJob, DuplicateJobError, findJob, listJobs } from "../registry";
5
- import { configureJobs, enqueueJob, resetJobRuntime, startJobWorkers } from "../runtime";
6
-
7
- afterEach(() => {
8
- clearJobs();
9
- resetJobRuntime();
10
- });
11
-
12
- describe("defineJob", () => {
13
- it("registers the definition under its name", () => {
14
- const job = defineJob({ name: "a.job", handle: () => Promise.resolve() });
15
-
16
- expect(job.name).toBe("a.job");
17
- expect(findJob("a.job")).toBe(job.definition);
18
- expect(listJobs()).toHaveLength(1);
19
- });
20
-
21
- it("refuses a duplicate name rather than replacing the handler", () => {
22
- defineJob({ name: "a.job", handle: () => Promise.resolve() });
23
-
24
- expect(() => defineJob({ name: "a.job", handle: () => Promise.resolve() })).toThrow(
25
- DuplicateJobError,
26
- );
27
- });
28
- });
29
-
30
- describe("enqueue", () => {
31
- it("runs the handler through the installed driver", async () => {
32
- const handle = vi.fn().mockResolvedValue(undefined);
33
- const job = defineJob<{ id: string }>({ name: "a.job", handle });
34
- configureJobs({ driver: createInlineJobDriver({ logger: silentLogger() }) });
35
-
36
- const result = await job.enqueue({ id: "x" });
37
-
38
- expect(result).toEqual({ enqueued: true });
39
- expect(handle).toHaveBeenCalledWith(
40
- { id: "x" },
41
- expect.objectContaining({ attempt: 1, maxAttempts: 1 }),
42
- );
43
- });
44
-
45
- it("reports rather than throws when no driver is configured", async () => {
46
- const job = defineJob({ name: "a.job", handle: () => Promise.resolve() });
47
-
48
- await expect(job.enqueue()).resolves.toEqual({
49
- enqueued: false,
50
- reason: "no-driver",
51
- });
52
- });
53
-
54
- it("reports rather than throws when the driver itself fails", async () => {
55
- const definition = { name: "a.job", handle: () => Promise.resolve() };
56
- configureJobs({
57
- logger: silentLogger(),
58
- driver: {
59
- kind: "broken",
60
- enqueue: () => Promise.reject(new Error("redis is down")),
61
- start: () => Promise.resolve(),
62
- stop: () => Promise.resolve(),
63
- },
64
- });
65
-
66
- await expect(enqueueJob(definition, undefined, {})).resolves.toEqual({
67
- enqueued: false,
68
- reason: "error",
69
- });
70
- });
71
- });
72
-
73
- describe("startJobWorkers", () => {
74
- it("starts every registered job once, ignoring a second call", async () => {
75
- defineJob({ name: "a.job", handle: () => Promise.resolve() });
76
- const start = vi.fn().mockResolvedValue(undefined);
77
- configureJobs({
78
- logger: silentLogger(),
79
- driver: {
80
- kind: "spy",
81
- enqueue: () => Promise.resolve({ enqueued: true }),
82
- start,
83
- stop: () => Promise.resolve(),
84
- },
85
- });
86
-
87
- await startJobWorkers();
88
- await startJobWorkers();
89
-
90
- expect(start).toHaveBeenCalledTimes(1);
91
- expect(start.mock.calls[0]?.[0]).toHaveLength(1);
92
- });
93
-
94
- it("refuses to start with no driver configured", async () => {
95
- await expect(startJobWorkers()).rejects.toThrow("no driver configured");
96
- });
97
- });
98
-
99
- function silentLogger() {
100
- return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
101
- }
@@ -1,42 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import type { AnyJobDefinition } from "../../core/types";
4
- import { __testables } from "../bullmq";
5
-
6
- /**
7
- * The queue-concurrency rule. Pinned on its own because getting it wrong is
8
- * SILENT: a job that asked for single-flight would simply run concurrently,
9
- * and nothing would fail — the sweeps would just start racing each other again.
10
- */
11
- const { resolveConcurrency, DEFAULT_CONCURRENCY } = __testables;
12
-
13
- function job(overrides: Partial<AnyJobDefinition> = {}): AnyJobDefinition {
14
- return { name: "a.job", handle: () => Promise.resolve(), ...overrides };
15
- }
16
-
17
- describe("resolveConcurrency", () => {
18
- it("falls back to the default when no job on the queue states one", () => {
19
- expect(resolveConcurrency([job(), job({ name: "b.job" })])).toBe(DEFAULT_CONCURRENCY);
20
- });
21
-
22
- it("honours a stated 1 instead of raising it to the default", () => {
23
- // The regression this exists for: `Math.max(DEFAULT, ...)` silently turns
24
- // single-flight back into the default and undoes the guarantee.
25
- expect(resolveConcurrency([job({ concurrency: 1 })])).toBe(1);
26
- });
27
-
28
- it("takes the highest STATED value when several are given", () => {
29
- expect(
30
- resolveConcurrency([job({ concurrency: 1 }), job({ name: "b.job", concurrency: 4 })]),
31
- ).toBe(4);
32
- });
33
-
34
- it("ignores a stated value alongside unstated ones rather than averaging in the default", () => {
35
- expect(resolveConcurrency([job({ concurrency: 2 }), job({ name: "b.job" })])).toBe(2);
36
- });
37
-
38
- it("ignores a nonsensical value", () => {
39
- expect(resolveConcurrency([job({ concurrency: 0 })])).toBe(DEFAULT_CONCURRENCY);
40
- expect(resolveConcurrency([job({ concurrency: -3 })])).toBe(DEFAULT_CONCURRENCY);
41
- });
42
- });
@@ -1,79 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
-
3
- import type { AnyJobDefinition } from "../../core/types";
4
- import { createInlineJobDriver } from "../inline";
5
-
6
- function silentLogger() {
7
- return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
8
- }
9
-
10
- function definition(overrides: Partial<AnyJobDefinition> = {}): AnyJobDefinition {
11
- return { name: "a.job", handle: () => Promise.resolve(), ...overrides };
12
- }
13
-
14
- describe("inline driver", () => {
15
- it("awaits the handler so a test sees the side effect", async () => {
16
- const seen: string[] = [];
17
- const driver = createInlineJobDriver({ logger: silentLogger() });
18
-
19
- await driver.enqueue(
20
- definition({ handle: (payload) => {
21
- seen.push(payload as string);
22
- return Promise.resolve();
23
- } }),
24
- "one",
25
- {},
26
- );
27
-
28
- expect(seen).toEqual(["one"]);
29
- });
30
-
31
- it("retries up to `attempts` and records the run", async () => {
32
- const handle = vi
33
- .fn()
34
- .mockRejectedValueOnce(new Error("transient"))
35
- .mockResolvedValueOnce(undefined);
36
- const driver = createInlineJobDriver({ logger: silentLogger() });
37
-
38
- await driver.enqueue(definition({ attempts: 3, handle }), undefined, {});
39
-
40
- expect(handle).toHaveBeenCalledTimes(2);
41
- expect(driver.runs).toEqual([
42
- { name: "a.job", payload: undefined, attempts: 2, error: undefined },
43
- ]);
44
- });
45
-
46
- it("swallows a handler that fails every attempt, logging it once", async () => {
47
- const logger = silentLogger();
48
- const handle = vi.fn().mockRejectedValue(new Error("permanent"));
49
- const driver = createInlineJobDriver({ logger });
50
-
51
- await expect(
52
- driver.enqueue(definition({ attempts: 2, handle }), undefined, {}),
53
- ).resolves.toEqual({ enqueued: true });
54
-
55
- expect(handle).toHaveBeenCalledTimes(2);
56
- expect(logger.error).toHaveBeenCalledTimes(1);
57
- expect(driver.runs[0]?.error).toBeInstanceOf(Error);
58
- });
59
-
60
- it("warns that a delay is ignored instead of silently dropping it", async () => {
61
- const logger = silentLogger();
62
- const driver = createInlineJobDriver({ logger });
63
-
64
- await driver.enqueue(definition(), undefined, { delayMs: 3_000 });
65
-
66
- expect(logger.warn).toHaveBeenCalledWith(
67
- expect.stringContaining("ignored a 3000ms delay"),
68
- );
69
- });
70
-
71
- it("warns that registered schedules will never fire", async () => {
72
- const logger = silentLogger();
73
- const driver = createInlineJobDriver({ logger });
74
-
75
- await driver.start([definition({ schedule: { pattern: "0 * * * *" } })]);
76
-
77
- expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("a.job"));
78
- });
79
- });
@@ -1,57 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { InvalidRedisUrlError, parseRedisUrl } from "../redis-url";
4
-
5
- describe("parseRedisUrl", () => {
6
- it("defaults host, port and database", () => {
7
- expect(parseRedisUrl("redis://localhost")).toEqual({
8
- host: "localhost",
9
- port: 6379,
10
- username: undefined,
11
- password: undefined,
12
- db: undefined,
13
- tls: undefined,
14
- maxRetriesPerRequest: null,
15
- enableReadyCheck: false,
16
- });
17
- });
18
-
19
- it("reads credentials, port and database from the URL", () => {
20
- const parsed = parseRedisUrl("redis://user:p%40ss@redis:6380/3");
21
-
22
- expect(parsed).toMatchObject({
23
- host: "redis",
24
- port: 6380,
25
- username: "user",
26
- password: "p@ss",
27
- db: 3,
28
- });
29
- });
30
-
31
- it("enables TLS for rediss://", () => {
32
- expect(parseRedisUrl("rediss://redis:6379").tls).toEqual({});
33
- });
34
-
35
- it("always pins the two options BullMQ requires", () => {
36
- const parsed = parseRedisUrl("redis://redis:6379/1");
37
-
38
- expect(parsed.maxRetriesPerRequest).toBeNull();
39
- expect(parsed.enableReadyCheck).toBe(false);
40
- });
41
-
42
- it("rejects a non-Redis protocol", () => {
43
- expect(() => parseRedisUrl("http://redis:6379")).toThrow(InvalidRedisUrlError);
44
- });
45
-
46
- it("rejects a malformed URL", () => {
47
- expect(() => parseRedisUrl("not a url")).toThrow(InvalidRedisUrlError);
48
- });
49
-
50
- it("never puts the URL (which can carry a password) in the error message", () => {
51
- expect(() => parseRedisUrl("http://user:hunter2@redis:6379")).toThrow(
52
- expect.objectContaining({
53
- message: expect.not.stringContaining("hunter2") as unknown as string,
54
- }),
55
- );
56
- });
57
- });