@nwire/queue 0.7.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Gefter / 200apps Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @nwire/queue
2
+
3
+ > Background-job transport — queue contract + InMemory default + worker factory.
4
+
5
+ ## What it does
6
+
7
+ Defines a small `QueueTransport` interface (`enqueue` / `consume` / `close`) and ships an in-memory default for dev/tests that honors `delayMs` via `setTimeout`. `createQueueWorker(app, transport, { subscribe })` binds named queues to Nwire actions so background jobs reuse the same handler shape as HTTP. Production deployments plug in `@nwire/queue-bullmq` (Redis); the contract is identical.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @nwire/queue
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ ```ts
18
+ import { InMemoryQueueTransport, createQueueWorker } from "@nwire/queue";
19
+
20
+ const transport = new InMemoryQueueTransport();
21
+
22
+ const worker = createQueueWorker(app, transport, {
23
+ subscribe: [
24
+ { queue: "emails", action: "sendWelcomeEmail" },
25
+ { queue: "reports", action: "generateMonthlyReport", retry: { attempts: 3 } },
26
+ ],
27
+ });
28
+
29
+ await transport.enqueue("emails", { userId: "u-1" });
30
+ await worker.start();
31
+ // worker pulls from "emails" → dispatches sendWelcomeEmail with the payload
32
+ ```
33
+
34
+ ## API surface
35
+
36
+ - `QueueTransport` — interface adapters implement.
37
+ - `InMemoryQueueTransport` — process-local default; `delayMs`-aware.
38
+ - `createQueueWorker(app, transport, opts)` — bind queues to actions.
39
+ - `QueueMessage` / `QueueConsumer` / `QueueWorker` / `QueueSubscription` / `CreateQueueWorkerOptions` — supporting types.
40
+
41
+ ## When to use
42
+
43
+ Whenever a request shouldn't block the caller — sending emails, building reports, syncing to a third party. Same handler shape as an HTTP action, so behavior is identical across transports. Fits L3 and up.
44
+
45
+ ## Standalone use
46
+
47
+ For developers using `@nwire/queue` **without the rest of Nwire** — pair it with any TypeScript project, any container, any HTTP framework.
48
+
49
+ ```ts
50
+ // See the package's main entry (src/) for the standalone surface.
51
+ // The exports below work without @nwire/app or @nwire/forge.
52
+ import {} from /* ...standalone exports... */ "@nwire/queue";
53
+ ```
54
+
55
+ ## Within nwire-app
56
+
57
+ For developers using this package as part of the Nwire stack — register it via `app.use(...)` or it auto-wires when you compose `createApp({ modules })`.
58
+
59
+ ```ts
60
+ import { createApp } from "@nwire/forge";
61
+
62
+ const app = createApp({
63
+ /* ...config... */
64
+ });
65
+ // Adapter/plugin wiring happens here when applicable.
66
+ ```
67
+
68
+ ## See also
69
+
70
+ - [Architecture sketch §05 — Interfaces tier](../../architecture-sketch.html#packages)
71
+ - Sibling packages: [@nwire/queue-bullmq](../nwire-queue-bullmq), [@nwire/dead-letter](../nwire-dead-letter), [@nwire/cron](../nwire-cron)
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Queue worker — contract test. Producer enqueues a message; consumer
3
+ * dispatches the bound action; actor state reflects the message's effect.
4
+ *
5
+ * Wire-mode invariance proof: the same handler+actor that the HTTP wire uses
6
+ * runs identically when triggered through a queue. Domain code unchanged.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=queue.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/queue.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Queue worker — contract test. Producer enqueues a message; consumer
3
+ * dispatches the bound action; actor state reflects the message's effect.
4
+ *
5
+ * Wire-mode invariance proof: the same handler+actor that the HTTP wire uses
6
+ * runs identically when triggered through a queue. Domain code unchanged.
7
+ */
8
+ import { describe, it, expect } from "vitest";
9
+ import { z } from "zod";
10
+ import { defineEvent } from "@nwire/messages";
11
+ import * as forge from "@nwire/forge";
12
+ import { seedEnvelope } from "@nwire/envelope";
13
+ import { InMemoryQueueTransport, createQueueWorker } from "../queue";
14
+ const { defineAction, defineActor, defineHandler, defineModule, eventFactory, createApp } = forge;
15
+ const PingedEventDef = defineEvent({
16
+ name: "demo.pinged",
17
+ schema: z.object({ pingId: z.string() }),
18
+ });
19
+ const Pinged = eventFactory(PingedEventDef);
20
+ const PingActor = defineActor("ping", {
21
+ schema: z.object({ pingId: z.string(), pings: z.number().optional() }),
22
+ key: "pingId",
23
+ initial: "idle",
24
+ states: {
25
+ idle: {
26
+ on: {
27
+ [PingedEventDef.name]: {
28
+ assign: (ctx, event) => {
29
+ const c = ctx;
30
+ const e = event;
31
+ return { pingId: e.pingId, pings: (c.pings ?? 0) + 1 };
32
+ },
33
+ },
34
+ },
35
+ },
36
+ },
37
+ });
38
+ const ping = defineAction({ name: "demo.ping", schema: z.object({ pingId: z.string() }) });
39
+ const pingHandler = defineHandler(ping, async (input) => Pinged({ pingId: input.pingId }));
40
+ const demoModule = defineModule("demo", {
41
+ actions: [ping],
42
+ actors: [PingActor],
43
+ handlers: [pingHandler],
44
+ events: [PingedEventDef],
45
+ });
46
+ describe("queue worker — InMemoryQueueTransport drives actions through the runtime", () => {
47
+ it("an enqueued message dispatches the bound action and updates the actor", async () => {
48
+ const app = createApp({ modules: [demoModule] });
49
+ const transport = new InMemoryQueueTransport();
50
+ const worker = createQueueWorker(app, transport, {
51
+ subscribe: [{ queue: "demo.ping", action: ping }],
52
+ });
53
+ await app.start();
54
+ await worker.start();
55
+ await transport.enqueue("demo.ping", {
56
+ input: { pingId: "p-1" },
57
+ envelope: seedEnvelope({}),
58
+ });
59
+ const stored = await app.runtime.getActorStore().load("ping", "p-1");
60
+ expect(stored).not.toBeNull();
61
+ expect((stored?.data).pings).toBe(1);
62
+ await worker.stop();
63
+ });
64
+ it("queue messages carry their tenant — actor state stays in that tenant's scope", async () => {
65
+ const app = createApp({ modules: [demoModule] });
66
+ const transport = new InMemoryQueueTransport();
67
+ const worker = createQueueWorker(app, transport, {
68
+ subscribe: [{ queue: "demo.ping", action: ping }],
69
+ });
70
+ await worker.start();
71
+ await transport.enqueue("demo.ping", {
72
+ input: { pingId: "p-2" },
73
+ envelope: seedEnvelope({ tenant: "school-tlv" }),
74
+ });
75
+ await transport.enqueue("demo.ping", {
76
+ input: { pingId: "p-2" },
77
+ envelope: seedEnvelope({ tenant: "school-jlm" }),
78
+ });
79
+ const tlv = await app.runtime.getActorStore().load("ping", "p-2", "school-tlv");
80
+ const jlm = await app.runtime.getActorStore().load("ping", "p-2", "school-jlm");
81
+ const def = await app.runtime.getActorStore().load("ping", "p-2");
82
+ expect((tlv?.data).pings).toBe(1);
83
+ expect((jlm?.data).pings).toBe(1);
84
+ expect(def).toBeNull();
85
+ await worker.stop();
86
+ });
87
+ it("delayed messages fire after their delayMs (durable timer transport pattern)", async () => {
88
+ const app = createApp({ modules: [demoModule] });
89
+ const transport = new InMemoryQueueTransport();
90
+ const worker = createQueueWorker(app, transport, {
91
+ subscribe: [{ queue: "demo.ping", action: ping }],
92
+ });
93
+ await worker.start();
94
+ // 50ms delay — quick for tests.
95
+ await transport.enqueue("demo.ping", {
96
+ input: { pingId: "p-delayed" },
97
+ envelope: seedEnvelope({}),
98
+ delayMs: 50,
99
+ });
100
+ // Not yet — message is in the future.
101
+ const before = await app.runtime.getActorStore().load("ping", "p-delayed");
102
+ expect(before).toBeNull();
103
+ // Wait past the delay.
104
+ await new Promise((resolve) => setTimeout(resolve, 100));
105
+ await transport.flush();
106
+ const after = await app.runtime.getActorStore().load("ping", "p-delayed");
107
+ expect(after).not.toBeNull();
108
+ expect((after?.data).pings).toBe(1);
109
+ await worker.stop();
110
+ });
111
+ });
112
+ //# sourceMappingURL=queue.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue.test.js","sourceRoot":"","sources":["../../src/__tests__/queue.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `QueueTransport` — pluggable queue abstraction. Different deployments use
3
+ * different queue backends (BullMQ + Redis for prod, in-memory for tests, GCP
4
+ * Pub/Sub for the cross-region setup); the framework's promise is that the
5
+ * domain code doesn't change. Each transport routes published messages to
6
+ * registered consumers; consumers turn them back into action dispatches.
7
+ *
8
+ * const transport = new InMemoryQueueTransport()
9
+ * transport.subscribe('submissions.send-review-reminder', async (msg) => {
10
+ * await runtime.dispatch(sendReviewReminder, msg.input, msg.envelope)
11
+ * })
12
+ * await transport.enqueue('submissions.send-review-reminder', {
13
+ * input: { submissionId: 'xyz' },
14
+ * envelope: seedEnvelope({ tenant: 'school-tlv' })
15
+ * })
16
+ *
17
+ * Production swap: `new BullMQQueueTransport({ connection })` against the
18
+ * same interface. The queue worker wire's boot just changes the transport.
19
+ */
20
+ import type { MessageEnvelope } from "@nwire/envelope";
21
+ export interface QueueMessage<TInput = unknown> {
22
+ readonly queue: string;
23
+ readonly input: TInput;
24
+ readonly envelope: MessageEnvelope;
25
+ /**
26
+ * Optional delay in ms before the message becomes visible to consumers.
27
+ * Production: BullMQ's `delay` option; in-memory: setTimeout.
28
+ */
29
+ readonly delayMs?: number;
30
+ /** Stable id for at-least-once dedup (idempotency). Optional. */
31
+ readonly messageId?: string;
32
+ }
33
+ export type QueueConsumer<TInput = unknown> = (msg: QueueMessage<TInput>) => Promise<void>;
34
+ export interface QueueTransport {
35
+ enqueue<TInput>(queue: string, message: Omit<QueueMessage<TInput>, "queue">): Promise<void>;
36
+ subscribe<TInput>(queue: string, consumer: QueueConsumer<TInput>): void;
37
+ /** Drain in-flight work and stop consumers. */
38
+ stop(): Promise<void>;
39
+ }
40
+ /**
41
+ * Process-local queue. Writes go to a per-queue array; subscribers receive
42
+ * each message in FIFO order. `delayMs` is honored via setTimeout.
43
+ *
44
+ * For tests: deterministic. For dev: works without Redis. Not safe across
45
+ * processes — production wires use BullMQ or equivalent.
46
+ */
47
+ export declare class InMemoryQueueTransport implements QueueTransport {
48
+ private readonly subscribers;
49
+ private readonly pending;
50
+ private readonly inflight;
51
+ private stopped;
52
+ enqueue<TInput>(queue: string, message: Omit<QueueMessage<TInput>, "queue">): Promise<void>;
53
+ subscribe<TInput>(queue: string, consumer: QueueConsumer<TInput>): void;
54
+ stop(): Promise<void>;
55
+ /** Test-only — drain pending delayed messages synchronously. */
56
+ flush(): Promise<void>;
57
+ }
58
+ //# sourceMappingURL=queue-transport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue-transport.d.ts","sourceRoot":"","sources":["../src/queue-transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,MAAM,WAAW,YAAY,CAAC,MAAM,GAAG,OAAO;IAC5C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,MAAM,aAAa,CAAC,MAAM,GAAG,OAAO,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC,MAAM,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAE3F,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5F,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IACxE,+CAA+C;IAC/C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED;;;;;;GAMG;AACH,qBAAa,sBAAuB,YAAW,cAAc;IAC3D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsC;IAClE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA6B;IACrD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAuB;IAChD,OAAO,CAAC,OAAO,CAAS;IAElB,OAAO,CAAC,MAAM,EAClB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,GAC3C,OAAO,CAAC,IAAI,CAAC;IAiChB,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,IAAI;IAMjE,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ3B,gEAAgE;IAC1D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B"}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `QueueTransport` — pluggable queue abstraction. Different deployments use
3
+ * different queue backends (BullMQ + Redis for prod, in-memory for tests, GCP
4
+ * Pub/Sub for the cross-region setup); the framework's promise is that the
5
+ * domain code doesn't change. Each transport routes published messages to
6
+ * registered consumers; consumers turn them back into action dispatches.
7
+ *
8
+ * const transport = new InMemoryQueueTransport()
9
+ * transport.subscribe('submissions.send-review-reminder', async (msg) => {
10
+ * await runtime.dispatch(sendReviewReminder, msg.input, msg.envelope)
11
+ * })
12
+ * await transport.enqueue('submissions.send-review-reminder', {
13
+ * input: { submissionId: 'xyz' },
14
+ * envelope: seedEnvelope({ tenant: 'school-tlv' })
15
+ * })
16
+ *
17
+ * Production swap: `new BullMQQueueTransport({ connection })` against the
18
+ * same interface. The queue worker wire's boot just changes the transport.
19
+ */
20
+ /**
21
+ * Process-local queue. Writes go to a per-queue array; subscribers receive
22
+ * each message in FIFO order. `delayMs` is honored via setTimeout.
23
+ *
24
+ * For tests: deterministic. For dev: works without Redis. Not safe across
25
+ * processes — production wires use BullMQ or equivalent.
26
+ */
27
+ export class InMemoryQueueTransport {
28
+ subscribers = new Map();
29
+ pending = new Set();
30
+ inflight = [];
31
+ stopped = false;
32
+ async enqueue(queue, message) {
33
+ if (this.stopped) {
34
+ throw new Error("InMemoryQueueTransport: enqueue after stop");
35
+ }
36
+ const full = { queue, ...message };
37
+ const fan = async () => {
38
+ const consumers = this.subscribers.get(queue) ?? [];
39
+ for (const consumer of consumers) {
40
+ const promise = consumer(full).catch((err) => {
41
+ // Surface in dev console; production transports route
42
+ // failed messages to their own DLQ.
43
+ // eslint-disable-next-line no-console
44
+ console.error(`InMemoryQueueTransport consumer for "${queue}" threw:`, err);
45
+ });
46
+ this.inflight.push(promise);
47
+ await promise;
48
+ }
49
+ };
50
+ if (full.delayMs && full.delayMs > 0) {
51
+ const timer = setTimeout(() => {
52
+ this.pending.delete(timer);
53
+ this.inflight.push(fan());
54
+ }, full.delayMs);
55
+ if (typeof timer.unref === "function")
56
+ timer.unref();
57
+ this.pending.add(timer);
58
+ }
59
+ else {
60
+ // Inline-await so callers and tests see results synchronously
61
+ // when no delay is requested.
62
+ await fan();
63
+ }
64
+ }
65
+ subscribe(queue, consumer) {
66
+ const list = this.subscribers.get(queue) ?? [];
67
+ list.push(consumer);
68
+ this.subscribers.set(queue, list);
69
+ }
70
+ async stop() {
71
+ this.stopped = true;
72
+ for (const t of this.pending)
73
+ clearTimeout(t);
74
+ this.pending.clear();
75
+ await Promise.allSettled(this.inflight);
76
+ this.subscribers.clear();
77
+ }
78
+ /** Test-only — drain pending delayed messages synchronously. */
79
+ async flush() {
80
+ await Promise.allSettled([...this.inflight]);
81
+ }
82
+ }
83
+ //# sourceMappingURL=queue-transport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue-transport.js","sourceRoot":"","sources":["../src/queue-transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AA0BH;;;;;;GAMG;AACH,MAAM,OAAO,sBAAsB;IAChB,WAAW,GAAG,IAAI,GAAG,EAA2B,CAAC;IACjD,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAC;IACpC,QAAQ,GAAoB,EAAE,CAAC;IACxC,OAAO,GAAG,KAAK,CAAC;IAExB,KAAK,CAAC,OAAO,CACX,KAAa,EACb,OAA4C;QAE5C,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YACjB,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;QAChE,CAAC;QACD,MAAM,IAAI,GAAyB,EAAE,KAAK,EAAE,GAAG,OAAO,EAAE,CAAC;QACzD,MAAM,GAAG,GAAG,KAAK,IAAI,EAAE;YACrB,MAAM,SAAS,GAAG,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;YACpD,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;gBACjC,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;oBAC3C,sDAAsD;oBACtD,oCAAoC;oBACpC,sCAAsC;oBACtC,OAAO,CAAC,KAAK,CAAC,wCAAwC,KAAK,UAAU,EAAE,GAAG,CAAC,CAAC;gBAC9E,CAAC,CAAC,CAAC;gBACH,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;gBAC5B,MAAM,OAAO,CAAC;YAChB,CAAC;QACH,CAAC,CAAC;QAEF,IAAI,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,OAAO,GAAG,CAAC,EAAE,CAAC;YACrC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBAC5B,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBAC3B,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;YAC5B,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;YACjB,IAAI,OAAO,KAAK,CAAC,KAAK,KAAK,UAAU;gBAAE,KAAK,CAAC,KAAK,EAAE,CAAC;YACrD,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAC1B,CAAC;aAAM,CAAC;YACN,8DAA8D;YAC9D,8BAA8B;YAC9B,MAAM,GAAG,EAAE,CAAC;QACd,CAAC;IACH,CAAC;IAED,SAAS,CAAS,KAAa,EAAE,QAA+B;QAC9D,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QAC/C,IAAI,CAAC,IAAI,CAAC,QAAyB,CAAC,CAAC;QACrC,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;IACpC,CAAC;IAED,KAAK,CAAC,IAAI;QACR,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC;QACpB,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,OAAO;YAAE,YAAY,CAAC,CAAC,CAAC,CAAC;QAC9C,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;QACrB,MAAM,OAAO,CAAC,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACxC,IAAI,CAAC,WAAW,CAAC,KAAK,EAAE,CAAC;IAC3B,CAAC;IAED,gEAAgE;IAChE,KAAK,CAAC,KAAK;QACT,MAAM,OAAO,CAAC,UAAU,CAAC,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;IAC/C,CAAC;CACF"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Queue worker — binds queue messages to action dispatches.
3
+ *
4
+ * const queue = new InMemoryQueueTransport()
5
+ * const worker = createQueueWorker(app, queue, {
6
+ * subscribe: [
7
+ * { queue: 'submissions.send-review-reminder', action: sendReviewReminder },
8
+ * { queue: 'lessons.auto-abandon-attempt', action: autoAbandonAttempt }
9
+ * ]
10
+ * })
11
+ * await worker.start()
12
+ *
13
+ * // Producer side (HTTP wire or another worker):
14
+ * await queue.enqueue('submissions.send-review-reminder', {
15
+ * input: { submissionId },
16
+ * envelope: seedEnvelope({ tenant: 'school-tlv' }),
17
+ * delayMs: 3 * 86_400_000
18
+ * })
19
+ *
20
+ * The worker is a thin adapter — every queue→action binding is one line. It
21
+ * doesn't know about Redis; the transport does. Swap `InMemoryQueueTransport`
22
+ * for `BullMQQueueTransport` and the same wire boots in production.
23
+ *
24
+ * Wire-mode invariance: domain handlers are the same in HTTP and queue wires.
25
+ * The framework decides where dispatch happens; the handler is unchanged.
26
+ */
27
+ import type * as forge from "@nwire/forge";
28
+ import type { QueueTransport } from "./queue-transport";
29
+ type App = forge.App;
30
+ type ActionDefinition = forge.ActionDefinition;
31
+ export interface QueueSubscription {
32
+ /** Queue name to subscribe to. Convention: `<domain>.<verb-noun>`. */
33
+ readonly queue: string;
34
+ /** Action to dispatch when a message arrives. Input + envelope come from the message. */
35
+ readonly action: ActionDefinition;
36
+ }
37
+ export interface CreateQueueWorkerOptions {
38
+ readonly subscribe: readonly QueueSubscription[];
39
+ }
40
+ export interface QueueWorker {
41
+ readonly app: App;
42
+ readonly transport: QueueTransport;
43
+ start(): Promise<void>;
44
+ stop(): Promise<void>;
45
+ }
46
+ export declare function createQueueWorker(app: App, transport: QueueTransport, options: CreateQueueWorkerOptions): QueueWorker;
47
+ export {};
48
+ //# sourceMappingURL=queue-worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue-worker.d.ts","sourceRoot":"","sources":["../src/queue-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,KAAK,KAAK,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,cAAc,EAAgB,MAAM,mBAAmB,CAAC;AAEtE,KAAK,GAAG,GAAG,KAAK,CAAC,GAAG,CAAC;AACrB,KAAK,gBAAgB,GAAG,KAAK,CAAC,gBAAgB,CAAC;AAE/C,MAAM,WAAW,iBAAiB;IAChC,sEAAsE;IACtE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,yFAAyF;IACzF,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;CACnC;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,SAAS,EAAE,SAAS,iBAAiB,EAAE,CAAC;CAClD;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAC;IACnC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,GAAG,EACR,SAAS,EAAE,cAAc,EACzB,OAAO,EAAE,wBAAwB,GAChC,WAAW,CAmBb"}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Queue worker — binds queue messages to action dispatches.
3
+ *
4
+ * const queue = new InMemoryQueueTransport()
5
+ * const worker = createQueueWorker(app, queue, {
6
+ * subscribe: [
7
+ * { queue: 'submissions.send-review-reminder', action: sendReviewReminder },
8
+ * { queue: 'lessons.auto-abandon-attempt', action: autoAbandonAttempt }
9
+ * ]
10
+ * })
11
+ * await worker.start()
12
+ *
13
+ * // Producer side (HTTP wire or another worker):
14
+ * await queue.enqueue('submissions.send-review-reminder', {
15
+ * input: { submissionId },
16
+ * envelope: seedEnvelope({ tenant: 'school-tlv' }),
17
+ * delayMs: 3 * 86_400_000
18
+ * })
19
+ *
20
+ * The worker is a thin adapter — every queue→action binding is one line. It
21
+ * doesn't know about Redis; the transport does. Swap `InMemoryQueueTransport`
22
+ * for `BullMQQueueTransport` and the same wire boots in production.
23
+ *
24
+ * Wire-mode invariance: domain handlers are the same in HTTP and queue wires.
25
+ * The framework decides where dispatch happens; the handler is unchanged.
26
+ */
27
+ export function createQueueWorker(app, transport, options) {
28
+ let started = false;
29
+ return {
30
+ app,
31
+ transport,
32
+ async start() {
33
+ if (started)
34
+ return;
35
+ started = true;
36
+ for (const subscription of options.subscribe) {
37
+ transport.subscribe(subscription.queue, async (msg) => {
38
+ await app.runtime.dispatch(subscription.action, msg.input, msg.envelope);
39
+ });
40
+ }
41
+ },
42
+ async stop() {
43
+ await transport.stop();
44
+ },
45
+ };
46
+ }
47
+ //# sourceMappingURL=queue-worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue-worker.js","sourceRoot":"","sources":["../src/queue-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AA0BH,MAAM,UAAU,iBAAiB,CAC/B,GAAQ,EACR,SAAyB,EACzB,OAAiC;IAEjC,IAAI,OAAO,GAAG,KAAK,CAAC;IAEpB,OAAO;QACL,GAAG;QACH,SAAS;QACT,KAAK,CAAC,KAAK;YACT,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,KAAK,MAAM,YAAY,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;gBAC7C,SAAS,CAAC,SAAS,CAAC,YAAY,CAAC,KAAK,EAAE,KAAK,EAAE,GAAiB,EAAE,EAAE;oBAClE,MAAM,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;gBAC3E,CAAC,CAAC,CAAC;YACL,CAAC;QACH,CAAC;QACD,KAAK,CAAC,IAAI;YACR,MAAM,SAAS,CAAC,IAAI,EAAE,CAAC;QACzB,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@nwire/queue` — queue transport contract + InMemory default + worker factory.
3
+ *
4
+ * QueueTransport interface — pluggable: in-memory (dev), BullMQ (prod), SQS, …
5
+ * InMemoryQueueTransport — process-local, honors delayMs via setTimeout
6
+ * createQueueWorker(app, transport, { subscribe }) — bind queue → action
7
+ *
8
+ * Production deployments use a persistent adapter like `@nwire/queue-bullmq`
9
+ * (Redis) or `@nwire/queue-sqs` (AWS). Same interface; swap by config.
10
+ */
11
+ export { InMemoryQueueTransport, type QueueTransport, type QueueMessage, type QueueConsumer, } from "./queue-transport";
12
+ export { createQueueWorker, type QueueWorker, type QueueSubscription, type CreateQueueWorkerOptions, } from "./queue-worker";
13
+ //# sourceMappingURL=queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue.d.ts","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EACL,sBAAsB,EACtB,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,aAAa,GACnB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EACL,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,wBAAwB,GAC9B,MAAM,gBAAgB,CAAC"}
package/dist/queue.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@nwire/queue` — queue transport contract + InMemory default + worker factory.
3
+ *
4
+ * QueueTransport interface — pluggable: in-memory (dev), BullMQ (prod), SQS, …
5
+ * InMemoryQueueTransport — process-local, honors delayMs via setTimeout
6
+ * createQueueWorker(app, transport, { subscribe }) — bind queue → action
7
+ *
8
+ * Production deployments use a persistent adapter like `@nwire/queue-bullmq`
9
+ * (Redis) or `@nwire/queue-sqs` (AWS). Same interface; swap by config.
10
+ */
11
+ export { InMemoryQueueTransport, } from "./queue-transport";
12
+ export { createQueueWorker, } from "./queue-worker";
13
+ //# sourceMappingURL=queue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue.js","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EACL,sBAAsB,GAIvB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EACL,iBAAiB,GAIlB,MAAM,gBAAgB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@nwire/queue",
3
+ "version": "0.7.0",
4
+ "description": "Nwire — queue transport contract + InMemory default + createQueueWorker. Production adapters land as @nwire/queue-bullmq, @nwire/queue-sqs, @nwire/queue-pgboss.",
5
+ "keywords": [
6
+ "bus",
7
+ "nwire",
8
+ "queue",
9
+ "transport",
10
+ "worker"
11
+ ],
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "type": "module",
17
+ "main": "./dist/queue.js",
18
+ "types": "./dist/queue.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "import": "./dist/queue.js",
22
+ "types": "./dist/queue.d.ts"
23
+ }
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "dependencies": {
29
+ "@nwire/forge": "0.7.0",
30
+ "@nwire/envelope": "0.7.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.19.9",
34
+ "typescript": "^5.9.3",
35
+ "vitest": "^4.0.18",
36
+ "zod": "^4.0.0",
37
+ "@nwire/messages": "0.7.0"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc",
41
+ "dev": "tsc --watch",
42
+ "typecheck": "tsc --noEmit"
43
+ }
44
+ }