@nestarc/inbox 0.0.0-stage → 0.1.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/LICENSE +21 -0
  3. package/README.md +295 -2
  4. package/dist/contracts/errors.d.ts +34 -0
  5. package/dist/contracts/errors.js +73 -0
  6. package/dist/contracts/errors.js.map +1 -0
  7. package/dist/contracts/types.d.ts +174 -0
  8. package/dist/contracts/types.js +3 -0
  9. package/dist/contracts/types.js.map +1 -0
  10. package/dist/inbox-admin.service.d.ts +44 -0
  11. package/dist/inbox-admin.service.js +102 -0
  12. package/dist/inbox-admin.service.js.map +1 -0
  13. package/dist/inbox.module.d.ts +13 -0
  14. package/dist/inbox.module.js +59 -0
  15. package/dist/inbox.module.js.map +1 -0
  16. package/dist/inbox.service.d.ts +22 -0
  17. package/dist/inbox.service.js +74 -0
  18. package/dist/inbox.service.js.map +1 -0
  19. package/dist/index.d.ts +9 -0
  20. package/dist/index.js +28 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/integrations/tenancy.d.ts +25 -0
  23. package/dist/integrations/tenancy.js +56 -0
  24. package/dist/integrations/tenancy.js.map +1 -0
  25. package/dist/internal/handler-context.d.ts +3 -0
  26. package/dist/internal/handler-context.js +39 -0
  27. package/dist/internal/handler-context.js.map +1 -0
  28. package/dist/internal/options.d.ts +5 -0
  29. package/dist/internal/options.js +86 -0
  30. package/dist/internal/options.js.map +1 -0
  31. package/dist/internal/reliability-core/abort.d.ts +2 -0
  32. package/dist/internal/reliability-core/abort.js +21 -0
  33. package/dist/internal/reliability-core/abort.js.map +1 -0
  34. package/dist/internal/reliability-core/index.d.ts +3 -0
  35. package/dist/internal/reliability-core/index.js +14 -0
  36. package/dist/internal/reliability-core/index.js.map +1 -0
  37. package/dist/internal/reliability-core/json.d.ts +13 -0
  38. package/dist/internal/reliability-core/json.js +125 -0
  39. package/dist/internal/reliability-core/json.js.map +1 -0
  40. package/dist/internal/reliability-core/retry.d.ts +8 -0
  41. package/dist/internal/reliability-core/retry.js +26 -0
  42. package/dist/internal/reliability-core/retry.js.map +1 -0
  43. package/dist/internal/validation.d.ts +6 -0
  44. package/dist/internal/validation.js +123 -0
  45. package/dist/internal/validation.js.map +1 -0
  46. package/dist/storage/prisma/prisma-inbox.storage.d.ts +30 -0
  47. package/dist/storage/prisma/prisma-inbox.storage.js +369 -0
  48. package/dist/storage/prisma/prisma-inbox.storage.js.map +1 -0
  49. package/dist/worker/inbox-worker.d.ts +31 -0
  50. package/dist/worker/inbox-worker.js +343 -0
  51. package/dist/worker/inbox-worker.js.map +1 -0
  52. package/docs/adr/0001-durable-inbox.md +40 -0
  53. package/docs/benchmarks/2026-10-27-local.md +31 -0
  54. package/docs/benchmarks/2026-10-28-preview1-local.md +87 -0
  55. package/docs/benchmarks/2026-10-28-preview1-run1.json +120 -0
  56. package/docs/benchmarks/2026-10-28-preview1-run2.json +120 -0
  57. package/docs/benchmarks/2026-10-28-preview1-run3.json +120 -0
  58. package/docs/implementation-roadmap.md +127 -0
  59. package/docs/package-plan.md +250 -0
  60. package/docs/releasing.md +83 -0
  61. package/docs/usage.md +266 -0
  62. package/docs/validation.md +139 -0
  63. package/examples/README.md +124 -0
  64. package/examples/compose.yaml +26 -0
  65. package/examples/package.json +42 -0
  66. package/examples/prisma/schema.prisma +29 -0
  67. package/examples/prisma.config.ts +10 -0
  68. package/examples/scripts/prepare-db.cjs +45 -0
  69. package/examples/scripts/smoke.cjs +37 -0
  70. package/examples/scripts/with-brokers.cjs +249 -0
  71. package/examples/scripts/with-rabbitmq.cjs +131 -0
  72. package/examples/sql/application.sql +14 -0
  73. package/examples/sql/tenancy.sql +26 -0
  74. package/examples/src/broker-common.ts +79 -0
  75. package/examples/src/bullmq-ingress/main.ts +165 -0
  76. package/examples/src/common.ts +112 -0
  77. package/examples/src/idempotency-ingress/main.ts +211 -0
  78. package/examples/src/kafka-ingress/main.ts +209 -0
  79. package/examples/src/outbox-to-inbox/main.ts +198 -0
  80. package/examples/src/rabbitmq-ingress/main.ts +203 -0
  81. package/examples/src/sqs-ingress/main.ts +273 -0
  82. package/examples/src/tenancy/main.ts +210 -0
  83. package/examples/src/webhook-ingress/main.ts +172 -0
  84. package/examples/tsconfig.json +16 -0
  85. package/package.json +90 -4
  86. package/src/sql/create-inbox-table.sql +100 -0
  87. package/src/sql/migrate-v1-to-v2.sql +53 -0
@@ -0,0 +1,211 @@
1
+ import "reflect-metadata";
2
+ import assert from "node:assert/strict";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { createServer } from "node:http";
5
+ import type { AddressInfo } from "node:net";
6
+ import { PostgresStorage } from "@nestarc/idempotency/postgres";
7
+ import {
8
+ InboxConflictError,
9
+ InboxService,
10
+ InboxValidationError,
11
+ } from "@nestarc/inbox";
12
+ import { Pool } from "pg";
13
+ import { exampleDatabaseUrl, startInbox, tenantId, waitUntil } from "../common";
14
+
15
+ /** An HTTP response cache may expire; the producer event ID must survive that expiry. */
16
+ async function main() {
17
+ const fixture = await startInbox("example.idempotency.v1", "order.created");
18
+ const pool = new Pool({ connectionString: exampleDatabaseUrl(), max: 4 });
19
+ const tableName = `inbox_example_http_${randomUUID().replaceAll("-", "")}`;
20
+ const storage = new PostgresStorage({ pool, tableName });
21
+ const inbox = fixture.app.get(InboxService);
22
+ let acceptCalls = 0;
23
+
24
+ const server = createServer((request, response) => {
25
+ const send = (status: number, body: string, replayed = false) => {
26
+ response.writeHead(status, {
27
+ "content-type": "application/json",
28
+ ...(replayed ? { "idempotency-replayed": "true" } : {}),
29
+ });
30
+ response.end(body);
31
+ };
32
+ void (async () => {
33
+ if (request.method !== "POST" || request.url !== "/events")
34
+ return send(404, '{"error":"not found"}');
35
+ const header = request.headers["idempotency-key"];
36
+ if (typeof header !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(header))
37
+ return send(400, '{"error":"invalid idempotency key"}');
38
+ const chunks: Buffer[] = [];
39
+ let size = 0;
40
+ for await (const chunk of request) {
41
+ const bytes = Buffer.from(chunk);
42
+ size += bytes.length;
43
+ if (size > 4096) return send(413, '{"error":"body too large"}');
44
+ chunks.push(bytes);
45
+ }
46
+ let event: { id: string; orderId: string };
47
+ try {
48
+ const input: unknown = JSON.parse(
49
+ Buffer.concat(chunks).toString("utf8"),
50
+ );
51
+ if (!input || typeof input !== "object" || Array.isArray(input))
52
+ throw new Error("invalid body");
53
+ const value = input as Record<string, unknown>;
54
+ if (
55
+ Object.keys(value).some((key) => !["id", "orderId"].includes(key)) ||
56
+ typeof value.id !== "string" ||
57
+ typeof value.orderId !== "string" ||
58
+ !/^[a-f0-9-]{36}$/.test(value.id) ||
59
+ !/^[a-f0-9-]{36}$/.test(value.orderId)
60
+ )
61
+ throw new Error("invalid event");
62
+ event = { id: value.id, orderId: value.orderId };
63
+ } catch {
64
+ return send(400, '{"error":"invalid event"}');
65
+ }
66
+
67
+ // This local fixture's authenticated account is fixed. A real route must authenticate
68
+ // before deriving tenantId and must scope its HTTP key by tenant and endpoint.
69
+ const key = `${tenantId}:POST:/events:${header}`;
70
+ const fingerprint = createHash("sha256")
71
+ .update(JSON.stringify(event))
72
+ .digest("hex");
73
+ const claim = await storage.create(key, fingerprint, 5);
74
+ if (!claim.acquired) {
75
+ const current = await storage.get(key);
76
+ if (!current) return send(503, '{"error":"retry acquisition"}');
77
+ if (current.fingerprint !== fingerprint)
78
+ return send(422, '{"error":"idempotency content conflict"}');
79
+ if (current.status !== "COMPLETED")
80
+ return send(409, '{"error":"request is processing"}');
81
+ return send(current.statusCode!, current.responseBody!, true);
82
+ }
83
+ const token = claim.token;
84
+ if (!token) return send(503, '{"error":"missing request ownership"}');
85
+ try {
86
+ acceptCalls++;
87
+ const receipt = await inbox.accept({
88
+ scope: { kind: "tenant", id: tenantId },
89
+ source: "example-http-client",
90
+ consumer: "example.idempotency.v1",
91
+ messageId: event.id,
92
+ eventType: "order.created",
93
+ schemaVersion: 1,
94
+ payload: { orderId: event.orderId },
95
+ });
96
+ const body = JSON.stringify({
97
+ inboxId: receipt.id,
98
+ kind: receipt.kind,
99
+ });
100
+ const completion = await storage.complete(
101
+ key,
102
+ token,
103
+ { statusCode: 202, body },
104
+ 1,
105
+ );
106
+ if (completion !== "ok")
107
+ return send(503, '{"error":"request ownership lost"}');
108
+ return send(202, body);
109
+ } catch (error) {
110
+ // A retry uses the same producer event ID, even if the acceptance result was lost.
111
+ await storage.delete(key, token).catch(() => undefined);
112
+ if (error instanceof InboxConflictError)
113
+ return send(422, '{"error":"event identity content conflict"}');
114
+ if (error instanceof InboxValidationError)
115
+ return send(400, '{"error":"invalid inbox envelope"}');
116
+ return send(503, '{"error":"acceptance unavailable"}');
117
+ }
118
+ })().catch(() => send(503, '{"error":"storage unavailable"}'));
119
+ });
120
+
121
+ try {
122
+ // A dedicated fixture only. Deploy the package's SQL migration explicitly in production.
123
+ await PostgresStorage.createSchema(pool, tableName);
124
+ await new Promise<void>((resolve, reject) => {
125
+ server.once("error", reject);
126
+ server.listen(0, "127.0.0.1", () => {
127
+ server.removeListener("error", reject);
128
+ resolve();
129
+ });
130
+ });
131
+ const endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}/events`;
132
+ const idempotencyKey = randomUUID();
133
+ const event = { id: randomUUID(), orderId: randomUUID() };
134
+ const post = (body = event) =>
135
+ fetch(endpoint, {
136
+ method: "POST",
137
+ headers: {
138
+ "content-type": "application/json",
139
+ "idempotency-key": idempotencyKey,
140
+ },
141
+ body: JSON.stringify(body),
142
+ signal: AbortSignal.timeout(5000),
143
+ });
144
+
145
+ const first = await post();
146
+ assert.equal(first.status, 202);
147
+ const firstBody = await first.text();
148
+ assert.equal(JSON.parse(firstBody).kind, "accepted");
149
+ const replay = await post();
150
+ assert.equal(replay.status, 202);
151
+ assert.equal(replay.headers.get("idempotency-replayed"), "true");
152
+ assert.equal(await replay.text(), firstBody);
153
+ assert.equal(acceptCalls, 1);
154
+ const conflict = await post({ ...event, orderId: randomUUID() });
155
+ assert.equal(conflict.status, 422);
156
+ await conflict.text();
157
+ assert.equal(acceptCalls, 1);
158
+
159
+ await waitUntil(
160
+ async () =>
161
+ (await storage.get(`${tenantId}:POST:/events:${idempotencyKey}`)) ===
162
+ null,
163
+ "actual HTTP response retention expiry",
164
+ 5000,
165
+ );
166
+ // The HTTP record has expired, but Inbox still rejects event identity reuse.
167
+ const durableConflict = await post({ ...event, orderId: randomUUID() });
168
+ assert.equal(durableConflict.status, 422);
169
+ await durableConflict.text();
170
+ const afterExpiry = await post();
171
+ assert.equal(afterExpiry.status, 202);
172
+ const afterBody = (await afterExpiry.json()) as {
173
+ inboxId: string;
174
+ kind: string;
175
+ };
176
+ assert.equal(afterBody.kind, "duplicate");
177
+ assert.equal(afterBody.inboxId, JSON.parse(firstBody).inboxId);
178
+ assert.equal(acceptCalls, 3);
179
+ await waitUntil(
180
+ async () =>
181
+ (await fixture.prisma.exampleProjection.count({
182
+ where: { tenantId, orderId: event.orderId },
183
+ })) === 1,
184
+ "one durable business projection despite HTTP cache expiry",
185
+ );
186
+ console.log(
187
+ JSON.stringify({
188
+ responseReplay: true,
189
+ afterExpiry: "duplicate",
190
+ projections: 1,
191
+ }),
192
+ );
193
+ } finally {
194
+ await new Promise<void>((resolve) => {
195
+ server.close(() => resolve());
196
+ server.closeAllConnections();
197
+ });
198
+ try {
199
+ await pool.query(`DROP TABLE IF EXISTS "${tableName}"`);
200
+ } finally {
201
+ await storage.close();
202
+ await pool.end();
203
+ await fixture.close();
204
+ }
205
+ }
206
+ }
207
+
208
+ void main().catch((error: unknown) => {
209
+ console.error(error);
210
+ process.exitCode = 1;
211
+ });
@@ -0,0 +1,209 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import { setTimeout } from "node:timers/promises";
4
+ import { Kafka, logLevel } from "kafkajs";
5
+ import {
6
+ InboxConflictError,
7
+ InboxService,
8
+ InboxValidationError,
9
+ } from "@nestarc/inbox";
10
+ import { tenantId, waitUntil } from "../common";
11
+ import { parseBrokerEvent, startBrokerInbox } from "../broker-common";
12
+
13
+ async function main() {
14
+ const broker = process.env.INBOX_EXAMPLE_KAFKA_BROKER;
15
+ if (!broker || !/^127\.0\.0\.1:\d+$/.test(broker))
16
+ throw new Error(
17
+ "Set INBOX_EXAMPLE_KAFKA_BROKER to the local fixture host:port.",
18
+ );
19
+ const name = `inbox-example-${randomUUID()}`;
20
+ const topic = `${name}-orders`;
21
+ const deadTopic = `${name}-dead`;
22
+ const kafka = new Kafka({
23
+ clientId: name,
24
+ brokers: [broker],
25
+ logLevel: logLevel.ERROR,
26
+ connectionTimeout: 5_000,
27
+ requestTimeout: 10_000,
28
+ retry: { retries: 3, initialRetryTime: 100, maxRetryTime: 1_000 },
29
+ });
30
+ const admin = kafka.admin();
31
+ const producer = kafka.producer();
32
+ const consumer = kafka.consumer({ groupId: name, sessionTimeout: 30_000 });
33
+ const fixture = await startBrokerInbox("example.kafka.v1", "order.created");
34
+ let callbackError: unknown;
35
+ let accepted = 0;
36
+ let quarantined = 0;
37
+ let transportRetries = 0;
38
+ let injectAcceptanceFailure = true;
39
+ const createdTopics: string[] = [];
40
+ try {
41
+ await admin.connect();
42
+ for (const candidate of [topic, deadTopic]) {
43
+ await admin.createTopics({
44
+ topics: [{ topic: candidate, numPartitions: 1, replicationFactor: 1 }],
45
+ waitForLeaders: true,
46
+ });
47
+ createdTopics.push(candidate);
48
+ }
49
+ await producer.connect();
50
+ await consumer.connect();
51
+ await consumer.subscribe({ topics: [topic], fromBeginning: true });
52
+ const inbox = fixture.app.get(InboxService);
53
+ await consumer.run({
54
+ autoCommit: false,
55
+ // Sequential processing inside each partition prevents committing across a gap.
56
+ partitionsConsumedConcurrently: 1,
57
+ eachMessage: async ({
58
+ topic: currentTopic,
59
+ partition,
60
+ message,
61
+ heartbeat,
62
+ }) => {
63
+ try {
64
+ let received = false;
65
+ for (let attempt = 1; attempt <= 3; attempt++) {
66
+ try {
67
+ if (!message.value || message.value.byteLength > 256 * 1024)
68
+ throw new InboxValidationError("Missing or oversized event.");
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(message.value.toString("utf8"));
72
+ } catch {
73
+ throw new InboxValidationError("Invalid JSON.");
74
+ }
75
+ const event = parseBrokerEvent(parsed);
76
+ // Fixture-only failure: demonstrate that this offset is not committed yet.
77
+ if (injectAcceptanceFailure) {
78
+ injectAcceptanceFailure = false;
79
+ throw new Error("Injected acceptance failure");
80
+ }
81
+ await inbox.accept({
82
+ scope: { kind: "tenant", id: tenantId },
83
+ source: "example-kafka-orders",
84
+ consumer: "example.kafka.v1",
85
+ messageId: event.eventId,
86
+ eventType: "order.created",
87
+ schemaVersion: 1,
88
+ payload: { orderId: event.orderId },
89
+ });
90
+ received = true;
91
+ accepted++;
92
+ break;
93
+ } catch (error) {
94
+ const permanent =
95
+ error instanceof InboxValidationError ||
96
+ error instanceof InboxConflictError;
97
+ if (permanent || attempt === 3) {
98
+ // A durable DLQ publish must succeed before advancing the source offset.
99
+ // A crash between publish and commit can duplicate DLQ entries; preserve
100
+ // source coordinates so the DLQ consumer can deduplicate them.
101
+ await producer.send({
102
+ topic: deadTopic,
103
+ acks: -1,
104
+ messages: [
105
+ {
106
+ key: `${currentTopic}/${partition}/${message.offset}`,
107
+ value: message.value,
108
+ headers: {
109
+ sourceTopic: currentTopic,
110
+ sourcePartition: String(partition),
111
+ sourceOffset: message.offset,
112
+ },
113
+ },
114
+ ],
115
+ });
116
+ quarantined++;
117
+ break;
118
+ }
119
+ transportRetries++;
120
+ await heartbeat();
121
+ await setTimeout(100 * attempt);
122
+ }
123
+ }
124
+ // Both durable acceptance and durable quarantine allow transport progress.
125
+ // Handler execution is asynchronous; its retry schedule belongs to Inbox.
126
+ await consumer.commitOffsets([
127
+ {
128
+ topic: currentTopic,
129
+ partition,
130
+ offset: String(BigInt(message.offset) + 1n),
131
+ },
132
+ ]);
133
+ if (received) await heartbeat();
134
+ } catch (error) {
135
+ callbackError = error;
136
+ throw error;
137
+ }
138
+ },
139
+ });
140
+ const orderId = randomUUID();
141
+ const eventId = randomUUID();
142
+ await producer.send({
143
+ topic,
144
+ acks: -1,
145
+ messages: [
146
+ { value: JSON.stringify({ eventId, orderId }) },
147
+ { value: JSON.stringify({ eventId, orderId }) },
148
+ { value: "invalid-json" },
149
+ ],
150
+ });
151
+ await waitUntil(
152
+ async () => {
153
+ if (callbackError) throw callbackError;
154
+ const offsets = await admin.fetchOffsets({
155
+ groupId: name,
156
+ topics: [topic],
157
+ });
158
+ return (
159
+ accepted === 2 &&
160
+ quarantined === 1 &&
161
+ offsets[0]?.partitions[0]?.offset === "3" &&
162
+ (await fixture.prisma.exampleProjection.count({
163
+ where: { orderId, tenantId },
164
+ })) === 1
165
+ );
166
+ },
167
+ "Kafka durable acceptance, contiguous offset commit, and quarantine",
168
+ 30_000,
169
+ );
170
+ assert.equal(transportRetries, 1);
171
+ assert.equal(fixture.retries(), 1);
172
+ const deadOffsets = await admin.fetchTopicOffsets(deadTopic);
173
+ assert.equal(deadOffsets[0]?.high, "1");
174
+ console.log(
175
+ JSON.stringify({
176
+ broker: "Kafka protocol / Redpanda fixture",
177
+ accepted,
178
+ quarantined,
179
+ transportRetries,
180
+ handlerRetries: fixture.retries(),
181
+ projections: 1,
182
+ }),
183
+ );
184
+ } finally {
185
+ try {
186
+ await consumer.disconnect();
187
+ } finally {
188
+ try {
189
+ await producer.disconnect();
190
+ } finally {
191
+ try {
192
+ if (createdTopics.length)
193
+ await admin.deleteTopics({ topics: createdTopics });
194
+ } finally {
195
+ try {
196
+ await admin.disconnect();
197
+ } finally {
198
+ await fixture.close();
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ void main().catch((error: unknown) => {
207
+ console.error(error);
208
+ process.exitCode = 1;
209
+ });
@@ -0,0 +1,198 @@
1
+ import "reflect-metadata";
2
+ import assert from "node:assert/strict";
3
+ import { randomUUID } from "node:crypto";
4
+ import { Injectable, Module } from "@nestjs/common";
5
+ import { NestFactory } from "@nestjs/core";
6
+ import {
7
+ OutboxModule,
8
+ OutboxEvent,
9
+ OutboxEmitter,
10
+ OnOutboxEvent,
11
+ type OutboxHandlerContext,
12
+ } from "@nestarc/outbox";
13
+ import {
14
+ InboxModule,
15
+ InboxService,
16
+ InboxPermanentError,
17
+ type InboxHandler,
18
+ } from "@nestarc/inbox";
19
+ import {
20
+ createPrisma,
21
+ tenantId,
22
+ waitUntil,
23
+ type OrderPayload,
24
+ } from "../common";
25
+ import type { Prisma } from "../generated/prisma/client";
26
+
27
+ class OrderCreated extends OutboxEvent {
28
+ static readonly eventType = "inbox-example.order.created";
29
+ constructor(public readonly orderId: string) {
30
+ super();
31
+ }
32
+ }
33
+ class OrderProjected extends OutboxEvent {
34
+ static readonly eventType = "inbox-example.order.projected";
35
+ constructor(public readonly orderId: string) {
36
+ super();
37
+ }
38
+ }
39
+
40
+ @Injectable()
41
+ class Bridge {
42
+ constructor(private readonly inbox: InboxService) {}
43
+
44
+ @OnOutboxEvent(OrderCreated)
45
+ async created(payload: OrderPayload, context: OutboxHandlerContext) {
46
+ if (context.tenantId !== tenantId)
47
+ throw new Error("Unexpected fixture tenant");
48
+ await this.inbox.accept({
49
+ scope: { kind: "tenant", id: tenantId },
50
+ source: "example-order-service",
51
+ consumer: "example.projection.v1",
52
+ messageId: context.eventId,
53
+ eventType: OrderCreated.eventType,
54
+ schemaVersion: 1,
55
+ payload,
56
+ });
57
+ }
58
+
59
+ @OnOutboxEvent(OrderProjected)
60
+ async projected(payload: OrderPayload, context: OutboxHandlerContext) {
61
+ if (context.tenantId !== tenantId)
62
+ throw new Error("Unexpected fixture tenant");
63
+ await this.inbox.accept({
64
+ scope: { kind: "tenant", id: tenantId },
65
+ source: "example-projection-service",
66
+ consumer: "example.receipt.v1",
67
+ messageId: context.eventId,
68
+ eventType: OrderProjected.eventType,
69
+ schemaVersion: 1,
70
+ payload,
71
+ });
72
+ }
73
+ }
74
+
75
+ async function main() {
76
+ const prisma = createPrisma();
77
+ const outboxModule = OutboxModule.forRoot({
78
+ prisma,
79
+ polling: { interval: 25 },
80
+ });
81
+ @Module({
82
+ imports: [
83
+ outboxModule,
84
+ InboxModule.forRootAsync({
85
+ imports: [outboxModule],
86
+ inject: [OutboxEmitter],
87
+ useFactory: (outbox: OutboxEmitter) => {
88
+ const handlers: InboxHandler<
89
+ OrderPayload,
90
+ Prisma.TransactionClient
91
+ >[] = [
92
+ {
93
+ consumer: "example.projection.v1",
94
+ eventType: OrderCreated.eventType,
95
+ schemaVersion: 1,
96
+ async handle(payload, ctx) {
97
+ if (ctx.scope.kind !== "tenant")
98
+ throw new InboxPermanentError();
99
+ await ctx.tx.exampleProjection.create({
100
+ data: { tenantId: ctx.scope.id, orderId: payload.orderId },
101
+ });
102
+ // This insert, the projection, and Inbox COMPLETED share ctx.tx.
103
+ await outbox.emit(ctx.tx, new OrderProjected(payload.orderId), {
104
+ tenantId: ctx.scope.id,
105
+ aggregateId: payload.orderId,
106
+ causationId: ctx.messageId,
107
+ });
108
+ },
109
+ },
110
+ {
111
+ consumer: "example.receipt.v1",
112
+ eventType: OrderProjected.eventType,
113
+ schemaVersion: 1,
114
+ async handle(payload, ctx) {
115
+ if (ctx.scope.kind !== "tenant")
116
+ throw new InboxPermanentError();
117
+ await ctx.tx.exampleReceipt.create({
118
+ data: { tenantId: ctx.scope.id, orderId: payload.orderId },
119
+ });
120
+ },
121
+ },
122
+ ];
123
+ return {
124
+ prisma,
125
+ handlers,
126
+ worker: { enabled: true, pollIntervalMs: 25, concurrency: 2 },
127
+ };
128
+ },
129
+ }),
130
+ ],
131
+ providers: [Bridge],
132
+ })
133
+ class AppModule {}
134
+
135
+ const app = await NestFactory.createApplicationContext(AppModule, {
136
+ logger: ["error", "warn"],
137
+ });
138
+ app.enableShutdownHooks();
139
+ try {
140
+ const orderId = randomUUID();
141
+ await prisma.$transaction(async (tx) => {
142
+ await tx.exampleOrder.create({ data: { id: orderId, tenantId } });
143
+ await app.get(OutboxEmitter).emit(tx, new OrderCreated(orderId), {
144
+ tenantId,
145
+ aggregateId: orderId,
146
+ });
147
+ });
148
+ await waitUntil(
149
+ async () =>
150
+ (await prisma.exampleReceipt.count({
151
+ where: { orderId, tenantId },
152
+ })) === 1,
153
+ "outbox → inbox → business write → outbox → downstream inbox",
154
+ );
155
+ const [event] = await prisma.$queryRawUnsafe<Array<{ id: string }>>(
156
+ "SELECT id FROM outbox_events WHERE aggregate_id = $1 AND event_type = $2",
157
+ orderId,
158
+ OrderCreated.eventType,
159
+ );
160
+ assert.ok(event);
161
+ const repeated = await app.get(InboxService).accept({
162
+ scope: { kind: "tenant", id: tenantId },
163
+ source: "example-order-service",
164
+ consumer: "example.projection.v1",
165
+ messageId: event.id,
166
+ eventType: OrderCreated.eventType,
167
+ schemaVersion: 1,
168
+ payload: { orderId },
169
+ });
170
+ assert.equal(repeated.kind, "duplicate");
171
+ assert.equal(
172
+ await prisma.exampleProjection.count({ where: { orderId, tenantId } }),
173
+ 1,
174
+ );
175
+ assert.equal(
176
+ await prisma.exampleReceipt.count({ where: { orderId, tenantId } }),
177
+ 1,
178
+ );
179
+ console.log(
180
+ JSON.stringify({
181
+ projections: 1,
182
+ downstreamReceipts: 1,
183
+ duplicateReplay: repeated.kind,
184
+ }),
185
+ );
186
+ } finally {
187
+ try {
188
+ await app.close();
189
+ } finally {
190
+ await prisma.$disconnect();
191
+ }
192
+ }
193
+ }
194
+
195
+ void main().catch((error: unknown) => {
196
+ console.error(error);
197
+ process.exitCode = 1;
198
+ });