@c9up/bay 0.1.3

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.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @c9up/bay — Background job queue for the Ream framework.
3
+ *
4
+ * Dispatch/process/retry/fail pattern with pluggable drivers (Memory, Redis).
5
+ *
6
+ * @implements MISS-11
7
+ */
8
+ export { MemoryDriver } from "./drivers/MemoryDriver.js";
9
+ export type { RedisClient } from "./drivers/RedisDriver.js";
10
+ export { RedisDriver } from "./drivers/RedisDriver.js";
11
+ export type { Job, JobHandler, QueueDriver } from "./QueueManager.js";
12
+ export { QueueManager } from "./QueueManager.js";
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,YAAY,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACvD,YAAY,EAAE,GAAG,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @c9up/bay — Background job queue for the Ream framework.
3
+ *
4
+ * Dispatch/process/retry/fail pattern with pluggable drivers (Memory, Redis).
5
+ *
6
+ * @implements MISS-11
7
+ */
8
+ export { MemoryDriver } from "./drivers/MemoryDriver.js";
9
+ export { RedisDriver } from "./drivers/RedisDriver.js";
10
+ export { QueueManager } from "./QueueManager.js";
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAEzD,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAEvD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Default `QueueManager` singleton — mirror of Adonis's
3
+ * `import queue from '@adonisjs/queue/services/main'` shape.
4
+ *
5
+ * Populated by either:
6
+ * - `BayProvider.boot()`, when the app uses `() => import('@c9up/bay/provider')`
7
+ * - The app itself, via `setQueue(myQueue)`, when it wires a
8
+ * custom-driver `QueueManager` outside the provider flow.
9
+ *
10
+ * import queue from '@c9up/bay/services/main'
11
+ *
12
+ * queue.register('send-email', new SendEmailJob())
13
+ * await queue.dispatch('send-email', { to: 'user@example.com' })
14
+ */
15
+ import type { QueueManager } from "../QueueManager.js";
16
+ /** @internal Bind the singleton (called by BayProvider or by the app). */
17
+ export declare function setQueue(value: QueueManager): void;
18
+ /** @internal Read the singleton (or `undefined` pre-boot). */
19
+ export declare function getQueue(): QueueManager | undefined;
20
+ declare const queue: QueueManager;
21
+ export default queue;
22
+ //# sourceMappingURL=main.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/services/main.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAIvD,0EAA0E;AAC1E,wBAAgB,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAElD;AAED,8DAA8D;AAC9D,wBAAgB,QAAQ,IAAI,YAAY,GAAG,SAAS,CAEnD;AAED,QAAA,MAAM,KAAK,EAAE,YAWX,CAAC;AAEH,eAAe,KAAK,CAAC"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Default `QueueManager` singleton — mirror of Adonis's
3
+ * `import queue from '@adonisjs/queue/services/main'` shape.
4
+ *
5
+ * Populated by either:
6
+ * - `BayProvider.boot()`, when the app uses `() => import('@c9up/bay/provider')`
7
+ * - The app itself, via `setQueue(myQueue)`, when it wires a
8
+ * custom-driver `QueueManager` outside the provider flow.
9
+ *
10
+ * import queue from '@c9up/bay/services/main'
11
+ *
12
+ * queue.register('send-email', new SendEmailJob())
13
+ * await queue.dispatch('send-email', { to: 'user@example.com' })
14
+ */
15
+ let instance;
16
+ /** @internal Bind the singleton (called by BayProvider or by the app). */
17
+ export function setQueue(value) {
18
+ instance = value;
19
+ }
20
+ /** @internal Read the singleton (or `undefined` pre-boot). */
21
+ export function getQueue() {
22
+ return instance;
23
+ }
24
+ const queue = new Proxy({}, {
25
+ get(_target, prop) {
26
+ if (!instance) {
27
+ throw new Error("[bay] QueueManager singleton accessed before BayProvider.boot() ran " +
28
+ "or `setQueue(myQueue)` was called. Wire one of them first.");
29
+ }
30
+ const value = Reflect.get(instance, prop, instance);
31
+ return typeof value === "function" ? value.bind(instance) : value;
32
+ },
33
+ });
34
+ export default queue;
35
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.js","sourceRoot":"","sources":["../../src/services/main.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,IAAI,QAAkC,CAAC;AAEvC,0EAA0E;AAC1E,MAAM,UAAU,QAAQ,CAAC,KAAmB;IAC3C,QAAQ,GAAG,KAAK,CAAC;AAClB,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,QAAQ;IACvB,OAAO,QAAQ,CAAC;AACjB,CAAC;AAED,MAAM,KAAK,GAAiB,IAAI,KAAK,CAAC,EAAkB,EAAE;IACzD,GAAG,CAAC,OAAO,EAAE,IAAI;QAChB,IAAI,CAAC,QAAQ,EAAE,CAAC;YACf,MAAM,IAAI,KAAK,CACd,sEAAsE;gBACrE,4DAA4D,CAC7D,CAAC;QACH,CAAC;QACD,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC;QACpD,OAAO,OAAO,KAAK,KAAK,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;IACnE,CAAC;CACD,CAAC,CAAC;AAEH,eAAe,KAAK,CAAC"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * In-memory `QueueDriver` for tests — captures every dispatched
3
+ * job and exposes Adonis/Laravel-style `assertPushed` /
4
+ * `assertNotPushed` helpers in the same shape as Rover's
5
+ * `FakeMail`.
6
+ *
7
+ * Not re-exported from the main barrel; reach via
8
+ * `@c9up/bay/testing` so production code never accidentally pulls
9
+ * the fake into a runtime build.
10
+ */
11
+ import type { Job, QueueDriver } from "../QueueManager.js";
12
+ export interface FakeQueuePredicate {
13
+ /** Custom payload predicate — receives the job's `payload` and
14
+ * returns `true` to match. The job name is always taken from the
15
+ * positional `name` argument of `assertPushed` / `assertNotPushed`
16
+ * — there is no `name` field on the predicate (would create a
17
+ * silent override footgun where `assertPushed('a', { name: 'b' })`
18
+ * matches 'b' but the error message says 'a'). */
19
+ payloadMatches?: (payload: unknown) => boolean;
20
+ }
21
+ export type FakeQueuePredicateArg = FakeQueuePredicate | ((job: Job) => boolean);
22
+ export declare class FakeQueue implements QueueDriver {
23
+ #private;
24
+ push(job: Job): Promise<void>;
25
+ /** Always returns `null` — fake queues never auto-dispatch.
26
+ * Tests that need handler execution should use the memory
27
+ * driver directly. */
28
+ pop(): Promise<Job | null>;
29
+ fail(job: Job, error: string): Promise<void>;
30
+ complete(job: Job): Promise<void>;
31
+ retry(job: Job): Promise<void>;
32
+ failed(): Promise<Job[]>;
33
+ size(): Promise<number>;
34
+ /**
35
+ * Defensive snapshot of every captured job. Each entry is a
36
+ * shallow clone so test-side mutations can't bleed back into the
37
+ * internal capture store — avoids cross-test contamination.
38
+ */
39
+ getPushed(): Job[];
40
+ reset(): void;
41
+ assertPushed(name: string, predicate?: FakeQueuePredicateArg): void;
42
+ assertNotPushed(name: string, predicate?: FakeQueuePredicateArg): void;
43
+ }
44
+ //# sourceMappingURL=FakeQueue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FakeQueue.d.ts","sourceRoot":"","sources":["../../src/testing/FakeQueue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAE3D,MAAM,WAAW,kBAAkB;IAClC;;;;;uDAKmD;IACnD,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC;CAC/C;AAED,MAAM,MAAM,qBAAqB,GAC9B,kBAAkB,GAClB,CAAC,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,CAAC;AAE3B,qBAAa,SAAU,YAAW,WAAW;;IAGtC,IAAI,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;IAYnC;;2BAEuB;IACjB,GAAG,IAAI,OAAO,CAAC,GAAG,GAAG,IAAI,CAAC;IAI1B,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAM5C,QAAQ,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;IAMjC,KAAK,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;IA0B9B,MAAM,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;IAMxB,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC;IAI7B;;;;OAIG;IACH,SAAS,IAAI,GAAG,EAAE;IAIlB,KAAK,IAAI,IAAI;IAIb,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,qBAAqB,GAAG,IAAI;IAQnE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,qBAAqB,GAAG,IAAI;CAQtE"}
@@ -0,0 +1,131 @@
1
+ /**
2
+ * In-memory `QueueDriver` for tests — captures every dispatched
3
+ * job and exposes Adonis/Laravel-style `assertPushed` /
4
+ * `assertNotPushed` helpers in the same shape as Rover's
5
+ * `FakeMail`.
6
+ *
7
+ * Not re-exported from the main barrel; reach via
8
+ * `@c9up/bay/testing` so production code never accidentally pulls
9
+ * the fake into a runtime build.
10
+ */
11
+ export class FakeQueue {
12
+ #pushed = [];
13
+ async push(job) {
14
+ // Reject duplicate ids to surface the most common test-fixture
15
+ // mistake — two `makeJob({ id: 'x' })` reused across pushes
16
+ // silently corrupts later `fail`/`complete`/`retry` lookups.
17
+ if (this.#pushed.some((j) => j.id === job.id)) {
18
+ throw new Error(`FakeQueue: duplicate job id '${job.id}' — each push must use a unique id (the real drivers enforce this implicitly via crypto.randomUUID()).`);
19
+ }
20
+ this.#pushed.push(job);
21
+ }
22
+ /** Always returns `null` — fake queues never auto-dispatch.
23
+ * Tests that need handler execution should use the memory
24
+ * driver directly. */
25
+ async pop() {
26
+ return null;
27
+ }
28
+ async fail(job, error) {
29
+ const found = this.#requireJob(job, "fail");
30
+ found.status = "failed";
31
+ found.error = error;
32
+ }
33
+ async complete(job) {
34
+ const found = this.#requireJob(job, "complete");
35
+ found.status = "completed";
36
+ found.processedAt = Date.now();
37
+ }
38
+ async retry(job) {
39
+ const found = this.#requireJob(job, "retry");
40
+ found.attempts += 1;
41
+ found.status = "pending";
42
+ // Reset transient state from the prior failure so a retried
43
+ // job's invariants match a fresh push (a real driver would
44
+ // either drop these on requeue or rely on the worker to clear
45
+ // them — we mirror "drop").
46
+ found.error = undefined;
47
+ found.processedAt = undefined;
48
+ }
49
+ /** Look up the captured copy of a job by id. Throws when the id
50
+ * isn't present — silent no-op on a missing job is the most
51
+ * insidious test bug (caller's local Job ref shows the new
52
+ * status while the FakeQueue's internal capture is unchanged). */
53
+ #requireJob(job, verb) {
54
+ const found = this.#pushed.find((j) => j.id === job.id);
55
+ if (!found) {
56
+ throw new Error(`FakeQueue.${verb}: job id '${job.id}' is not in the queue. Did you forget to push it, or is it from a different FakeQueue instance?`);
57
+ }
58
+ return found;
59
+ }
60
+ async failed() {
61
+ return this.#pushed
62
+ .filter((j) => j.status === "failed")
63
+ .map((j) => ({ ...j }));
64
+ }
65
+ async size() {
66
+ return this.#pushed.filter((j) => j.status === "pending").length;
67
+ }
68
+ /**
69
+ * Defensive snapshot of every captured job. Each entry is a
70
+ * shallow clone so test-side mutations can't bleed back into the
71
+ * internal capture store — avoids cross-test contamination.
72
+ */
73
+ getPushed() {
74
+ return this.#pushed.map((j) => ({ ...j }));
75
+ }
76
+ reset() {
77
+ this.#pushed = [];
78
+ }
79
+ assertPushed(name, predicate) {
80
+ const match = makeMatcher(name, predicate);
81
+ if (this.#pushed.some(match))
82
+ return;
83
+ throw new Error(`queue.assertPushed('${name}'${describePredicate(predicate)}) failed — no captured job matches.\n${describeCaptured(this.#pushed)}`);
84
+ }
85
+ assertNotPushed(name, predicate) {
86
+ const match = makeMatcher(name, predicate);
87
+ const found = this.#pushed.find(match);
88
+ if (!found)
89
+ return;
90
+ throw new Error(`queue.assertNotPushed('${name}'${describePredicate(predicate)}) failed — at least one captured job matches.\n${describeCaptured(this.#pushed)}`);
91
+ }
92
+ }
93
+ function makeMatcher(name, predicate) {
94
+ // Function-form predicate — caller does ALL the matching, the
95
+ // `name` arg is still a hard prerequisite.
96
+ if (typeof predicate === "function") {
97
+ return (j) => j.name === name && predicate(j);
98
+ }
99
+ if (predicate === undefined) {
100
+ return (j) => j.name === name;
101
+ }
102
+ // Object-form: positional `name` is the contract; the predicate
103
+ // narrows further via `payloadMatches`.
104
+ return (j) => {
105
+ if (j.name !== name)
106
+ return false;
107
+ if (predicate.payloadMatches && !predicate.payloadMatches(j.payload)) {
108
+ return false;
109
+ }
110
+ return true;
111
+ };
112
+ }
113
+ function describePredicate(predicate) {
114
+ if (predicate === undefined)
115
+ return "";
116
+ if (typeof predicate === "function")
117
+ return ", <function predicate>";
118
+ // Empty object collapses to a name-only match — say so explicitly
119
+ // so a mistaken `assertPushed('x', {})` is not mistaken for "I'm
120
+ // narrowing by some predicate I forgot to fill in".
121
+ if (Object.keys(predicate).length === 0)
122
+ return ", <empty predicate (name-only)>";
123
+ return `, ${JSON.stringify(predicate)}`;
124
+ }
125
+ function describeCaptured(captured) {
126
+ if (captured.length === 0)
127
+ return "Captured: (none)";
128
+ const lines = captured.map((j, i) => ` [${i}] name="${j.name}" status=${j.status} attempts=${j.attempts}/${j.maxAttempts}`);
129
+ return `Captured (${captured.length}):\n${lines.join("\n")}`;
130
+ }
131
+ //# sourceMappingURL=FakeQueue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FakeQueue.js","sourceRoot":"","sources":["../../src/testing/FakeQueue.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@c9up/bay",
3
+ "version": "0.1.3",
4
+ "description": "Queue — pluggable job-queue contract with memory + Redis drivers for the Ream framework",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./provider": {
15
+ "types": "./dist/BayProvider.d.ts",
16
+ "import": "./dist/BayProvider.js"
17
+ },
18
+ "./services/main": {
19
+ "types": "./dist/services/main.d.ts",
20
+ "import": "./dist/services/main.js"
21
+ },
22
+ "./testing": {
23
+ "types": "./dist/testing/FakeQueue.d.ts",
24
+ "import": "./dist/testing/FakeQueue.js"
25
+ }
26
+ },
27
+ "peerDependencies": {
28
+ "@c9up/ream": "^0.1.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.19.15",
32
+ "typescript": "^6.0.2",
33
+ "vitest": "^4.1.2"
34
+ },
35
+ "engines": {
36
+ "node": ">=22.0.0"
37
+ },
38
+ "files": [
39
+ "src",
40
+ "dist",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@c9up/ream": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/C9up/bay.git"
55
+ },
56
+ "scripts": {
57
+ "build": "tsc -p tsconfig.build.json",
58
+ "test": "vitest run",
59
+ "lint": "biome check src/",
60
+ "test:coverage": "vitest run --coverage",
61
+ "typecheck": "tsc --noEmit"
62
+ }
63
+ }
@@ -0,0 +1,85 @@
1
+ import { MemoryDriver } from "./drivers/MemoryDriver.js";
2
+ import { QueueManager } from "./QueueManager.js";
3
+ import { setQueue } from "./services/main.js";
4
+
5
+ /**
6
+ * Slim, duck-typed host context — bay stays publishable without
7
+ * importing `@c9up/ream`. Any framework that exposes a Container with
8
+ * `singleton(token, factory)` + `resolve(token)` and a config store
9
+ * with `get(key)` satisfies the contract.
10
+ */
11
+ interface BayContainer {
12
+ singleton(token: unknown, factory: () => unknown): void;
13
+ resolve<T = unknown>(token: unknown): T;
14
+ }
15
+ interface BayConfigStore {
16
+ get<T = unknown>(key: string): T | undefined;
17
+ }
18
+ export interface BayAppContext {
19
+ container: BayContainer;
20
+ config: BayConfigStore;
21
+ }
22
+
23
+ export interface BayProviderConfig {
24
+ /**
25
+ * Driver to bind by default. Recognized strings: `"memory"`,
26
+ * `"redis"`. For any other case (custom driver, pre-built
27
+ * instance), wire `QueueManager` directly in your app's startup
28
+ * and skip the provider — the `services/main` singleton accepts
29
+ * `setQueue(myQueue)` from outside.
30
+ *
31
+ * Default `"memory"`.
32
+ */
33
+ driver?: "memory" | "redis";
34
+ }
35
+
36
+ /**
37
+ * BayProvider — registers a default in-memory `QueueManager` in the
38
+ * host container so apps that don't need custom driver wiring can
39
+ * `import queue from '@c9up/bay/services/main'` and dispatch
40
+ * straight away. Job handlers are still registered manually via
41
+ * `queue.register(name, handler)` — that's intrinsic to the queue
42
+ * design (handlers are app-defined, not config-driven).
43
+ *
44
+ * Apps with non-trivial wiring (Redis driver, custom queue config)
45
+ * can ignore this provider and bind their own `QueueManager` instance
46
+ * in the container; the `services/main` proxy resolves whatever is
47
+ * registered.
48
+ *
49
+ * // reamrc.ts
50
+ * providers: [() => import('@c9up/bay/provider')]
51
+ *
52
+ * // start/queue.ts
53
+ * import queue from '@c9up/bay/services/main'
54
+ *
55
+ * queue.register('send-email', new SendEmailJob())
56
+ * await queue.dispatch('send-email', { to: 'user@example.com' })
57
+ */
58
+ export default class BayProvider {
59
+ constructor(protected app: BayAppContext) {}
60
+
61
+ register(): void {
62
+ this.app.container.singleton(QueueManager, () => {
63
+ const config = this.app.config.get<BayProviderConfig>("queue");
64
+ const driverName = config?.driver ?? "memory";
65
+ if (driverName !== "memory") {
66
+ throw new Error(
67
+ `[bay] Unsupported driver '${driverName}' for default provider — ` +
68
+ "wire QueueManager yourself in start/queue.ts for non-memory drivers.",
69
+ );
70
+ }
71
+ return new QueueManager(new MemoryDriver());
72
+ });
73
+ this.app.container.singleton("queue", () =>
74
+ this.app.container.resolve<QueueManager>(QueueManager),
75
+ );
76
+ }
77
+
78
+ async boot(): Promise<void> {
79
+ // Populate the `@c9up/bay/services/main` singleton so apps can
80
+ // `import queue from '@c9up/bay/services/main'` from anywhere.
81
+ setQueue(this.app.container.resolve<QueueManager>(QueueManager));
82
+ }
83
+
84
+ async shutdown(): Promise<void> {}
85
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * QueueManager — dispatch and process background jobs.
3
+ *
4
+ * Usage:
5
+ * queue.register('send-email', new SendEmailHandler())
6
+ * await queue.dispatch('send-email', { to: 'user@example.com' })
7
+ * queue.work()
8
+ */
9
+
10
+ export interface Job {
11
+ id: string;
12
+ name: string;
13
+ payload: unknown;
14
+ attempts: number;
15
+ maxAttempts: number;
16
+ status: "pending" | "processing" | "completed" | "failed";
17
+ error?: string;
18
+ createdAt: number;
19
+ processedAt?: number;
20
+ }
21
+
22
+ export interface JobHandler {
23
+ handle(payload: unknown): Promise<void>;
24
+ }
25
+
26
+ export interface QueueDriver {
27
+ push(job: Job): Promise<void>;
28
+ pop(): Promise<Job | null>;
29
+ fail(job: Job, error: string): Promise<void>;
30
+ complete(job: Job): Promise<void>;
31
+ retry(job: Job): Promise<void>;
32
+ failed(): Promise<Job[]>;
33
+ size(): Promise<number>;
34
+ }
35
+
36
+ export class QueueManager {
37
+ private driver: QueueDriver;
38
+ private handlers: Map<string, JobHandler | (new () => JobHandler)> =
39
+ new Map();
40
+ private running = false;
41
+ private inflightPromise: Promise<boolean> | null = null;
42
+
43
+ constructor(driver: QueueDriver) {
44
+ this.driver = driver;
45
+ }
46
+
47
+ /** Register a job handler. */
48
+ register(name: string, handler: JobHandler | (new () => JobHandler)): void {
49
+ this.handlers.set(name, handler);
50
+ }
51
+
52
+ /** Dispatch a job to the queue. */
53
+ async dispatch(
54
+ name: string,
55
+ payload: unknown,
56
+ options?: { maxAttempts?: number },
57
+ ): Promise<string> {
58
+ if (options?.maxAttempts !== undefined && options.maxAttempts < 1) {
59
+ throw new Error("maxAttempts must be >= 1");
60
+ }
61
+ const id = `job_${crypto.randomUUID()}`;
62
+ const job: Job = {
63
+ id,
64
+ name,
65
+ payload,
66
+ attempts: 0,
67
+ maxAttempts: options?.maxAttempts ?? 3,
68
+ status: "pending",
69
+ createdAt: Date.now(),
70
+ };
71
+ await this.driver.push(job);
72
+ return id;
73
+ }
74
+
75
+ /** Process the next job in the queue. */
76
+ async processOne(): Promise<boolean> {
77
+ const job = await this.driver.pop();
78
+ if (!job) return false;
79
+
80
+ const handlerOrClass = this.handlers.get(job.name);
81
+ if (!handlerOrClass) {
82
+ process.stderr.write(
83
+ `QueueManager: no handler registered for job '${job.name}'\n`,
84
+ );
85
+ await this.driver.fail(job, `No handler registered for job: ${job.name}`);
86
+ return true;
87
+ }
88
+
89
+ const handler =
90
+ typeof handlerOrClass === "function"
91
+ ? new handlerOrClass()
92
+ : handlerOrClass;
93
+ job.attempts++;
94
+ job.status = "processing";
95
+ job.processedAt = Date.now();
96
+
97
+ try {
98
+ await handler.handle(job.payload);
99
+ job.status = "completed";
100
+ await this.driver.complete(job);
101
+ } catch (err) {
102
+ const errorMsg = err instanceof Error ? err.message : String(err);
103
+ if (job.attempts < job.maxAttempts) {
104
+ job.status = "pending";
105
+ await this.driver.retry(job);
106
+ } else {
107
+ job.status = "failed";
108
+ job.error = errorMsg;
109
+ await this.driver.fail(job, errorMsg);
110
+ }
111
+ }
112
+
113
+ return true;
114
+ }
115
+
116
+ /** Start processing jobs continuously. */
117
+ async work(pollIntervalMs = 1000): Promise<void> {
118
+ if (pollIntervalMs <= 0) {
119
+ throw new Error("pollIntervalMs must be positive");
120
+ }
121
+ if (this.running) {
122
+ throw new Error("QueueManager is already running");
123
+ }
124
+ this.running = true;
125
+ while (this.running) {
126
+ try {
127
+ this.inflightPromise = this.processOne();
128
+ const processed = await this.inflightPromise;
129
+ if (!processed) {
130
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
131
+ }
132
+ } catch (err) {
133
+ process.stderr.write(
134
+ `QueueManager processOne error: ${err instanceof Error ? err.message : String(err)}\n`,
135
+ );
136
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
137
+ } finally {
138
+ this.inflightPromise = null;
139
+ }
140
+ }
141
+ }
142
+
143
+ /** Await the currently in-flight processOne, if any. */
144
+ async drain(): Promise<void> {
145
+ if (this.inflightPromise) {
146
+ await this.inflightPromise.catch(() => {});
147
+ }
148
+ }
149
+
150
+ /** Stop the worker. */
151
+ async stop(): Promise<void> {
152
+ this.running = false;
153
+ await this.drain();
154
+ }
155
+
156
+ /** Get failed jobs. */
157
+ async failedJobs(): Promise<Job[]> {
158
+ return this.driver.failed();
159
+ }
160
+
161
+ /** Get queue size. */
162
+ async size(): Promise<number> {
163
+ return this.driver.size();
164
+ }
165
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Memory queue driver — in-process queue for development.
3
+ */
4
+
5
+ import type { Job, QueueDriver } from "../QueueManager.js";
6
+
7
+ export class MemoryDriver implements QueueDriver {
8
+ #pending: Job[] = [];
9
+ #failedJobs: Job[] = [];
10
+ #maxFailedJobs: number;
11
+
12
+ constructor(options?: { maxFailedJobs?: number }) {
13
+ this.#maxFailedJobs = options?.maxFailedJobs ?? 1000;
14
+ }
15
+
16
+ async push(job: Job): Promise<void> {
17
+ this.#pending.push(job);
18
+ }
19
+
20
+ async pop(): Promise<Job | null> {
21
+ return this.#pending.shift() ?? null;
22
+ }
23
+
24
+ async fail(job: Job, error: string): Promise<void> {
25
+ job.error = error;
26
+ job.status = "failed";
27
+ this.#failedJobs.push(job);
28
+ if (this.#failedJobs.length > this.#maxFailedJobs) {
29
+ this.#failedJobs.splice(0, this.#failedJobs.length - this.#maxFailedJobs);
30
+ }
31
+ }
32
+
33
+ async complete(_job: Job): Promise<void> {
34
+ // Nothing to do for memory driver
35
+ }
36
+
37
+ async retry(job: Job): Promise<void> {
38
+ job.status = "pending";
39
+ this.#pending.push(job);
40
+ }
41
+
42
+ async failed(): Promise<Job[]> {
43
+ return [...this.#failedJobs];
44
+ }
45
+
46
+ async size(): Promise<number> {
47
+ return this.#pending.length;
48
+ }
49
+ }