@nestarc/inbox 0.0.0-stage → 0.2.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 (98) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +309 -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/testing/index.d.ts +40 -0
  50. package/dist/testing/index.js +120 -0
  51. package/dist/testing/index.js.map +1 -0
  52. package/dist/worker/inbox-worker.d.ts +31 -0
  53. package/dist/worker/inbox-worker.js +343 -0
  54. package/dist/worker/inbox-worker.js.map +1 -0
  55. package/docs/adr/0001-durable-inbox.md +40 -0
  56. package/docs/benchmarks/2026-10-27-local.md +31 -0
  57. package/docs/benchmarks/2026-10-28-preview1-local.md +87 -0
  58. package/docs/benchmarks/2026-10-28-preview1-run1.json +120 -0
  59. package/docs/benchmarks/2026-10-28-preview1-run2.json +120 -0
  60. package/docs/benchmarks/2026-10-28-preview1-run3.json +120 -0
  61. package/docs/benchmarks/2026-10-28-sustained-local.md +104 -0
  62. package/docs/benchmarks/2026-10-28-sustained-run1.json +2553 -0
  63. package/docs/benchmarks/2026-10-28-sustained-run2.json +2553 -0
  64. package/docs/broker-recovery-results.json +52 -0
  65. package/docs/broker-recovery.md +30 -0
  66. package/docs/db-operations.md +41 -0
  67. package/docs/implementation-roadmap.md +129 -0
  68. package/docs/operations/2026-10-28-database.json +34 -0
  69. package/docs/package-plan.md +250 -0
  70. package/docs/releasing.md +91 -0
  71. package/docs/testing.md +86 -0
  72. package/docs/usage.md +266 -0
  73. package/docs/validation.md +174 -0
  74. package/examples/README.md +124 -0
  75. package/examples/compose.yaml +26 -0
  76. package/examples/package.json +42 -0
  77. package/examples/prisma/schema.prisma +29 -0
  78. package/examples/prisma.config.ts +10 -0
  79. package/examples/scripts/prepare-db.cjs +45 -0
  80. package/examples/scripts/smoke.cjs +37 -0
  81. package/examples/scripts/with-brokers.cjs +249 -0
  82. package/examples/scripts/with-rabbitmq.cjs +131 -0
  83. package/examples/sql/application.sql +14 -0
  84. package/examples/sql/tenancy.sql +26 -0
  85. package/examples/src/broker-common.ts +79 -0
  86. package/examples/src/bullmq-ingress/main.ts +165 -0
  87. package/examples/src/common.ts +112 -0
  88. package/examples/src/idempotency-ingress/main.ts +211 -0
  89. package/examples/src/kafka-ingress/main.ts +209 -0
  90. package/examples/src/outbox-to-inbox/main.ts +198 -0
  91. package/examples/src/rabbitmq-ingress/main.ts +203 -0
  92. package/examples/src/sqs-ingress/main.ts +273 -0
  93. package/examples/src/tenancy/main.ts +210 -0
  94. package/examples/src/webhook-ingress/main.ts +172 -0
  95. package/examples/tsconfig.json +16 -0
  96. package/package.json +99 -4
  97. package/src/sql/create-inbox-table.sql +100 -0
  98. package/src/sql/migrate-v1-to-v2.sql +53 -0
@@ -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
+ });
@@ -0,0 +1,203 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import { setTimeout } from "node:timers/promises";
4
+ import * as amqp from "amqplib";
5
+ import {
6
+ InboxConflictError,
7
+ InboxService,
8
+ InboxValidationError,
9
+ } from "@nestarc/inbox";
10
+ import { startInbox, tenantId, waitUntil } from "../common";
11
+
12
+ async function main() {
13
+ const brokerUrl =
14
+ process.env.INBOX_EXAMPLE_AMQP_URL ?? "amqp://guest:guest@127.0.0.1:55672";
15
+ if (
16
+ !["localhost", "127.0.0.1", "[::1]"].includes(new URL(brokerUrl).hostname)
17
+ ) {
18
+ throw new Error("This example connects only to a local RabbitMQ fixture.");
19
+ }
20
+ const fixture = await startInbox("example.rabbitmq.v1", "order.created");
21
+ const connection = await amqp
22
+ .connect(brokerUrl)
23
+ .catch(async (error: unknown) => {
24
+ await fixture.close();
25
+ throw error;
26
+ });
27
+ const channel = await connection
28
+ .createConfirmChannel()
29
+ .catch(async (error: unknown) => {
30
+ try {
31
+ await connection.close();
32
+ } finally {
33
+ await fixture.close();
34
+ }
35
+ throw error;
36
+ });
37
+ const prefix = `inbox-example-${randomUUID()}`;
38
+ const mainQueue = `${prefix}.main`;
39
+ const retryQueue = `${prefix}.retry`;
40
+ const deadQueue = `${prefix}.dead`;
41
+ let callbackError: unknown;
42
+ let acceptedDeliveries = 0;
43
+ const pending = new Set<Promise<void>>();
44
+ try {
45
+ await channel.assertQueue(deadQueue, { durable: true });
46
+ await channel.assertQueue(mainQueue, {
47
+ durable: true,
48
+ arguments: {
49
+ "x-dead-letter-exchange": "",
50
+ "x-dead-letter-routing-key": deadQueue,
51
+ },
52
+ });
53
+ await channel.assertQueue(retryQueue, {
54
+ durable: true,
55
+ arguments: {
56
+ "x-message-ttl": 1_000,
57
+ "x-dead-letter-exchange": "",
58
+ "x-dead-letter-routing-key": mainQueue,
59
+ },
60
+ });
61
+ await channel.prefetch(1);
62
+ const inbox = fixture.app.get(InboxService);
63
+ const subscription = await channel.consume(
64
+ mainQueue,
65
+ (message) => {
66
+ if (!message) return;
67
+ const processing = (async () => {
68
+ try {
69
+ if (
70
+ message.content.byteLength > 256 * 1024 ||
71
+ !message.properties.messageId
72
+ ) {
73
+ channel.nack(message, false, false);
74
+ return;
75
+ }
76
+ let payload: unknown;
77
+ try {
78
+ payload = JSON.parse(message.content.toString("utf8"));
79
+ } catch {
80
+ channel.nack(message, false, false);
81
+ return;
82
+ }
83
+ if (
84
+ !payload ||
85
+ typeof payload !== "object" ||
86
+ Array.isArray(payload) ||
87
+ typeof (payload as { orderId?: unknown }).orderId !== "string"
88
+ ) {
89
+ channel.nack(message, false, false);
90
+ return;
91
+ }
92
+ await inbox.accept({
93
+ // This fixture binds the authenticated broker route to one tenant.
94
+ // Production routes must derive this from trusted configuration.
95
+ scope: { kind: "tenant", id: tenantId },
96
+ source: "example-rabbitmq-orders",
97
+ consumer: "example.rabbitmq.v1",
98
+ messageId: message.properties.messageId,
99
+ eventType: "order.created",
100
+ schemaVersion: 1,
101
+ payload: payload as { orderId: string },
102
+ });
103
+ // Acceptance has committed. Handler failures are now Inbox's responsibility.
104
+ channel.ack(message);
105
+ acceptedDeliveries++;
106
+ } catch (error) {
107
+ if (
108
+ error instanceof InboxConflictError ||
109
+ error instanceof InboxValidationError
110
+ ) {
111
+ channel.nack(message, false, false);
112
+ return;
113
+ }
114
+ const previous = message.properties.headers?.["x-example-retries"];
115
+ const attempts =
116
+ typeof previous === "number" &&
117
+ Number.isSafeInteger(previous) &&
118
+ previous >= 0
119
+ ? previous
120
+ : 0;
121
+ if (attempts >= 3) {
122
+ channel.nack(message, false, false);
123
+ return;
124
+ }
125
+ try {
126
+ channel.sendToQueue(retryQueue, message.content, {
127
+ persistent: true,
128
+ messageId: message.properties.messageId,
129
+ contentType: "application/json",
130
+ headers: { "x-example-retries": attempts + 1 },
131
+ });
132
+ await channel.waitForConfirms();
133
+ channel.ack(message);
134
+ } catch (publishError) {
135
+ // Retain the unacknowledged delivery while backing off. With prefetch=1,
136
+ // this avoids an immediate nack/requeue loop during a database outage.
137
+ await setTimeout(1_000);
138
+ try {
139
+ channel.nack(message, false, true);
140
+ } catch {
141
+ /* connection loss requeues it */
142
+ }
143
+ callbackError = publishError;
144
+ }
145
+ }
146
+ })()
147
+ .catch((error: unknown) => {
148
+ callbackError = error;
149
+ })
150
+ .finally(() => {
151
+ pending.delete(processing);
152
+ });
153
+ pending.add(processing);
154
+ },
155
+ { noAck: false },
156
+ );
157
+
158
+ const orderId = randomUUID();
159
+ const messageId = randomUUID();
160
+ for (let attempt = 0; attempt < 2; attempt++) {
161
+ channel.sendToQueue(mainQueue, Buffer.from(JSON.stringify({ orderId })), {
162
+ persistent: true,
163
+ messageId,
164
+ contentType: "application/json",
165
+ });
166
+ }
167
+ await channel.waitForConfirms();
168
+ await waitUntil(async () => {
169
+ if (callbackError) throw callbackError;
170
+ return (
171
+ (await fixture.prisma.exampleProjection.count({
172
+ where: { orderId, tenantId },
173
+ })) === 1 && acceptedDeliveries === 2
174
+ );
175
+ }, "RabbitMQ duplicate delivery and one projection");
176
+ await channel.cancel(subscription.consumerTag);
177
+ await Promise.all(pending);
178
+ assert.equal(
179
+ await fixture.prisma.exampleProjection.count({
180
+ where: { orderId, tenantId },
181
+ }),
182
+ 1,
183
+ );
184
+ console.log(
185
+ JSON.stringify({ deliveriesPublished: 2, projections: 1, deadQueue }),
186
+ );
187
+ } finally {
188
+ try {
189
+ await channel.close();
190
+ } finally {
191
+ try {
192
+ await connection.close();
193
+ } finally {
194
+ await fixture.close();
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ void main().catch((error: unknown) => {
201
+ console.error(error);
202
+ process.exitCode = 1;
203
+ });
@@ -0,0 +1,273 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ ChangeMessageVisibilityCommand,
5
+ CreateQueueCommand,
6
+ DeleteMessageCommand,
7
+ DeleteQueueCommand,
8
+ GetQueueAttributesCommand,
9
+ ReceiveMessageCommand,
10
+ SendMessageCommand,
11
+ SQSClient,
12
+ type Message,
13
+ } from "@aws-sdk/client-sqs";
14
+ import {
15
+ InboxConflictError,
16
+ InboxService,
17
+ InboxValidationError,
18
+ } from "@nestarc/inbox";
19
+ import { tenantId, waitUntil } from "../common";
20
+ import { startBrokerInbox } from "../broker-common";
21
+
22
+ // This example checks the SQS API contract against local ElasticMQ. It is not an
23
+ // AWS infrastructure, IAM, availability, or production SQS compatibility test.
24
+ async function main() {
25
+ const configuredEndpoint = process.env.INBOX_EXAMPLE_SQS_ENDPOINT;
26
+ if (!configuredEndpoint)
27
+ throw new Error(
28
+ "Set INBOX_EXAMPLE_SQS_ENDPOINT to the local ElasticMQ fixture.",
29
+ );
30
+ const endpoint = new URL(configuredEndpoint);
31
+ if (
32
+ endpoint.protocol !== "http:" ||
33
+ !["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname) ||
34
+ endpoint.username ||
35
+ endpoint.password ||
36
+ endpoint.pathname !== "/" ||
37
+ endpoint.search ||
38
+ endpoint.hash
39
+ ) {
40
+ throw new Error("This example connects only to a local HTTP SQS fixture.");
41
+ }
42
+ const client = new SQSClient({
43
+ region: "us-east-1",
44
+ endpoint: endpoint.origin,
45
+ // Explicit fixture credentials prevent the SDK from reading a real AWS profile.
46
+ credentials: {
47
+ accessKeyId: "local-fixture",
48
+ secretAccessKey: "local-fixture",
49
+ },
50
+ maxAttempts: 3,
51
+ requestHandler: { connectionTimeout: 2_000, requestTimeout: 10_000 },
52
+ });
53
+ const fixture = await startBrokerInbox("example.sqs.v1", "order.created").catch(
54
+ (error: unknown) => {
55
+ client.destroy();
56
+ throw error;
57
+ },
58
+ );
59
+ const inbox = fixture.app.get(InboxService);
60
+ const prefix = `inbox-example-${randomUUID()}`;
61
+ const createdQueues: string[] = [];
62
+ const eventId = randomUUID();
63
+ const orderId = randomUUID();
64
+ let acceptedDeliveries = 0;
65
+ let quarantinedDeliveries = 0;
66
+ let transientFailures = 0;
67
+ let redelivered = false;
68
+ let injectAcceptanceFailure = true;
69
+
70
+ function localQueueUrl(value: string | undefined): string {
71
+ if (!value) throw new Error("The SQS fixture did not return a queue URL.");
72
+ // ElasticMQ advertises its container port by default. Preserve the queue path
73
+ // while using the explicitly configured mapped loopback port for every call.
74
+ const queue = new URL(value);
75
+ return new URL(queue.pathname, endpoint.origin).href;
76
+ }
77
+
78
+ async function createQueue(
79
+ suffix: string,
80
+ attributes: Record<string, string> = {},
81
+ ): Promise<string> {
82
+ const result = await client.send(
83
+ new CreateQueueCommand({
84
+ QueueName: `${prefix}-${suffix}`,
85
+ Attributes: attributes,
86
+ }),
87
+ );
88
+ const url = localQueueUrl(result.QueueUrl);
89
+ createdQueues.push(url);
90
+ return url;
91
+ }
92
+
93
+ async function deleteMessage(queueUrl: string, message: Message) {
94
+ assert.ok(
95
+ message.ReceiptHandle,
96
+ "Received messages must have a receipt handle.",
97
+ );
98
+ await client.send(
99
+ new DeleteMessageCommand({
100
+ QueueUrl: queueUrl,
101
+ ReceiptHandle: message.ReceiptHandle,
102
+ }),
103
+ );
104
+ }
105
+
106
+ try {
107
+ const deadQueue = await createQueue("dead");
108
+ const attributes = await client.send(
109
+ new GetQueueAttributesCommand({
110
+ QueueUrl: deadQueue,
111
+ AttributeNames: ["QueueArn"],
112
+ }),
113
+ );
114
+ assert.ok(attributes.Attributes?.QueueArn);
115
+ const queue = await createQueue("main", {
116
+ VisibilityTimeout: "30",
117
+ ReceiveMessageWaitTimeSeconds: "1",
118
+ RedrivePolicy: JSON.stringify({
119
+ deadLetterTargetArn: attributes.Attributes.QueueArn,
120
+ maxReceiveCount: "3",
121
+ }),
122
+ });
123
+ const body = JSON.stringify({ eventId, orderId });
124
+ for (let index = 0; index < 2; index++) {
125
+ await client.send(
126
+ new SendMessageCommand({ QueueUrl: queue, MessageBody: body }),
127
+ );
128
+ }
129
+ await client.send(
130
+ new SendMessageCommand({
131
+ QueueUrl: queue,
132
+ MessageBody: "invalid-json-fixture",
133
+ }),
134
+ );
135
+
136
+ const deadline = Date.now() + 40_000;
137
+ while (acceptedDeliveries < 2 || quarantinedDeliveries < 1) {
138
+ if (Date.now() > deadline)
139
+ throw new Error("SQS ingress fixture timed out.");
140
+ const result = await client.send(
141
+ new ReceiveMessageCommand({
142
+ QueueUrl: queue,
143
+ MaxNumberOfMessages: 1,
144
+ WaitTimeSeconds: 1,
145
+ VisibilityTimeout: 30,
146
+ MessageSystemAttributeNames: ["ApproximateReceiveCount"],
147
+ }),
148
+ );
149
+ for (const message of result.Messages ?? []) {
150
+ assert.ok(message.ReceiptHandle);
151
+ if (Number(message.Attributes?.ApproximateReceiveCount) > 1)
152
+ redelivered = true;
153
+ try {
154
+ if (!message.Body || Buffer.byteLength(message.Body) > 256 * 1024)
155
+ throw new InboxValidationError("Missing or oversized SQS body.");
156
+ let value: unknown;
157
+ try {
158
+ value = JSON.parse(message.Body);
159
+ } catch {
160
+ throw new InboxValidationError("Invalid SQS JSON body.");
161
+ }
162
+ if (
163
+ !value ||
164
+ typeof value !== "object" ||
165
+ Array.isArray(value) ||
166
+ typeof (value as { eventId?: unknown }).eventId !== "string" ||
167
+ typeof (value as { orderId?: unknown }).orderId !== "string"
168
+ ) {
169
+ throw new InboxValidationError(
170
+ "SQS eventId and orderId must be strings.",
171
+ );
172
+ }
173
+ const incoming = value as { eventId: string; orderId: string };
174
+ // Fault injection proves that acceptance failure leaves the broker copy
175
+ // available. Production code simply calls accept without this branch.
176
+ if (injectAcceptanceFailure) {
177
+ injectAcceptanceFailure = false;
178
+ throw new Error("Injected pre-acceptance storage failure");
179
+ }
180
+ await inbox.accept({
181
+ // Bind authenticated queue/account routes to a trusted tenant. Neither
182
+ // the SQS MessageId nor the receipt handle is the producer's event ID.
183
+ scope: { kind: "tenant", id: tenantId },
184
+ source: "example-sqs-orders",
185
+ consumer: "example.sqs.v1",
186
+ messageId: incoming.eventId,
187
+ eventType: "order.created",
188
+ schemaVersion: 1,
189
+ payload: { orderId: incoming.orderId },
190
+ });
191
+ } catch (error) {
192
+ if (
193
+ error instanceof InboxConflictError ||
194
+ error instanceof InboxValidationError
195
+ ) {
196
+ // Confirm quarantine persistence before deleting the original copy.
197
+ // A failed publish leaves the original available after visibility expiry.
198
+ await client.send(
199
+ new SendMessageCommand({
200
+ QueueUrl: deadQueue,
201
+ MessageBody: message.Body ?? "missing-body",
202
+ }),
203
+ );
204
+ await deleteMessage(queue, message);
205
+ quarantinedDeliveries++;
206
+ } else {
207
+ transientFailures++;
208
+ // No DeleteMessage on failed/uncertain acceptance. A short visibility
209
+ // backoff allows redelivery; queue redrive bounds persistent failures.
210
+ await client.send(
211
+ new ChangeMessageVisibilityCommand({
212
+ QueueUrl: queue,
213
+ ReceiptHandle: message.ReceiptHandle,
214
+ VisibilityTimeout: 1,
215
+ }),
216
+ );
217
+ }
218
+ continue;
219
+ }
220
+ // Both accepted and duplicate mean durable receipt. Inbox now owns handler
221
+ // retries, so this acknowledgement does not wait for business completion.
222
+ await deleteMessage(queue, message);
223
+ acceptedDeliveries++;
224
+ }
225
+ }
226
+ await waitUntil(
227
+ async () =>
228
+ (await fixture.prisma.exampleProjection.count({
229
+ where: { orderId, tenantId },
230
+ })) === 1,
231
+ "SQS duplicate deliveries and one projection",
232
+ );
233
+ const dead = await client.send(
234
+ new ReceiveMessageCommand({
235
+ QueueUrl: deadQueue,
236
+ MaxNumberOfMessages: 1,
237
+ WaitTimeSeconds: 1,
238
+ }),
239
+ );
240
+ assert.equal(dead.Messages?.[0]?.Body, "invalid-json-fixture");
241
+ assert.equal(transientFailures, 1);
242
+ assert.equal(redelivered, true);
243
+ assert.equal(acceptedDeliveries, 2);
244
+ assert.equal(quarantinedDeliveries, 1);
245
+ assert.ok(fixture.retries() >= 1, "Inbox owns retries after SQS acceptance.");
246
+ console.log(
247
+ JSON.stringify({
248
+ transport: "ElasticMQ (SQS-compatible emulator)",
249
+ acceptedDeliveries,
250
+ projections: 1,
251
+ transientFailures,
252
+ handlerRetries: fixture.retries(),
253
+ redelivered,
254
+ quarantinedDeliveries,
255
+ }),
256
+ );
257
+ } finally {
258
+ const cleanup = await Promise.allSettled(
259
+ createdQueues
260
+ .reverse()
261
+ .map((QueueUrl) => client.send(new DeleteQueueCommand({ QueueUrl }))),
262
+ );
263
+ client.destroy();
264
+ await fixture.close();
265
+ const failed = cleanup.find((result) => result.status === "rejected");
266
+ if (failed?.status === "rejected") throw failed.reason;
267
+ }
268
+ }
269
+
270
+ void main().catch((error: unknown) => {
271
+ console.error(error);
272
+ process.exitCode = 1;
273
+ });