@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,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
+ });
@@ -0,0 +1,210 @@
1
+ import "reflect-metadata";
2
+ import assert from "node:assert/strict";
3
+ import { randomBytes, randomUUID } from "node:crypto";
4
+ import { readFile } from "node:fs/promises";
5
+ import { resolve } from "node:path";
6
+ import { Module } from "@nestjs/common";
7
+ import { NestFactory } from "@nestjs/core";
8
+ import { PrismaPg } from "@prisma/adapter-pg";
9
+ import { Pool } from "pg";
10
+ import {
11
+ TenancyContext,
12
+ TenancyModule,
13
+ TenancyService,
14
+ } from "@nestarc/tenancy";
15
+ import {
16
+ InboxModule,
17
+ InboxService,
18
+ createInboxTenancyContext,
19
+ type InboxHandler,
20
+ type InboxScope,
21
+ } from "@nestarc/inbox";
22
+ import { PrismaClient, type Prisma } from "../generated/prisma/client";
23
+ import { exampleDatabaseUrl } from "../common";
24
+
25
+ type Payload = { targetTenant: string };
26
+ const consumer = "example.tenancy.v1";
27
+ const eventType = "example.tenant-work";
28
+
29
+ async function main() {
30
+ // This credential is only the isolated fixture's schema administrator. Business
31
+ // handlers use the separate role below, which cannot bypass RLS or own tables.
32
+ const admin = new Pool({ connectionString: exampleDatabaseUrl(), max: 2 });
33
+ const role = `inbox_example_${randomBytes(8).toString("hex")}`;
34
+ const password = randomBytes(24).toString("hex");
35
+ let roleCreated = false;
36
+ let prisma: PrismaClient | undefined;
37
+ let app:
38
+ | Awaited<ReturnType<typeof NestFactory.createApplicationContext>>
39
+ | undefined;
40
+ try {
41
+ await admin.query(
42
+ await readFile(resolve(__dirname, "../../sql/tenancy.sql"), "utf8"),
43
+ );
44
+ // Identifiers and credentials here consist exclusively of generated hex.
45
+ await admin.query(`CREATE ROLE ${role} LOGIN PASSWORD '${password}'
46
+ NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS`);
47
+ roleCreated = true;
48
+ await admin.query(`
49
+ GRANT USAGE ON SCHEMA public TO ${role};
50
+ GRANT SELECT ON public.nestarc_inbox_schema TO ${role};
51
+ GRANT SELECT, INSERT, UPDATE ON public.nestarc_inbox TO ${role};
52
+ GRANT SELECT, INSERT, UPDATE ON public.example_tenancy_records TO ${role};
53
+ GRANT SELECT, INSERT ON public.example_tenancy_shared TO ${role};
54
+ `);
55
+ const appUrl = new URL(exampleDatabaseUrl());
56
+ appUrl.username = role;
57
+ appUrl.password = password;
58
+ const client = new PrismaClient({
59
+ adapter: new PrismaPg({ connectionString: appUrl.toString(), max: 1 }),
60
+ });
61
+ prisma = client;
62
+ const tenancyModule = TenancyModule.forRoot({
63
+ tenantExtractor: "x-tenant-id",
64
+ });
65
+
66
+ @Module({
67
+ imports: [
68
+ tenancyModule,
69
+ InboxModule.forRootAsync({
70
+ imports: [tenancyModule],
71
+ inject: [TenancyService],
72
+ useFactory: (tenancy: TenancyService) => {
73
+ // TenancyModule exports the configured service, but not the context
74
+ // provider. Public TenancyContext instances share the package's ALS.
75
+ const context = new TenancyContext();
76
+ const handler: InboxHandler<Payload, Prisma.TransactionClient> = {
77
+ consumer,
78
+ eventType,
79
+ schemaVersion: 1,
80
+ async handle(payload, ctx) {
81
+ if (ctx.scope.kind === "global") {
82
+ assert.equal(tenancy.getCurrentTenant(), null);
83
+ assert.deepEqual(
84
+ await ctx.tx.$queryRawUnsafe(
85
+ "SELECT * FROM public.example_tenancy_records",
86
+ ),
87
+ [],
88
+ );
89
+ await ctx.tx.$executeRawUnsafe(
90
+ "INSERT INTO public.example_tenancy_shared VALUES ($1::uuid, $2::uuid)",
91
+ randomUUID(),
92
+ ctx.inboxId,
93
+ );
94
+ return;
95
+ }
96
+ assert.equal(tenancy.getCurrentTenantOrThrow(), ctx.scope.id);
97
+ const rows = await ctx.tx.$queryRawUnsafe<
98
+ Array<{ tenant_id: string }>
99
+ >("SELECT tenant_id FROM public.example_tenancy_records");
100
+ const tenantId = ctx.scope.id;
101
+ assert.ok(rows.every((row) => row.tenant_id === tenantId));
102
+ // Deliberately use the payload value so the forged fixture below
103
+ // demonstrates the database's WITH CHECK policy rejecting it.
104
+ await ctx.tx.$executeRawUnsafe(
105
+ "INSERT INTO public.example_tenancy_records VALUES ($1::uuid, $2, $3::uuid, 'handled')",
106
+ randomUUID(),
107
+ payload.targetTenant,
108
+ ctx.inboxId,
109
+ );
110
+ },
111
+ };
112
+ return {
113
+ prisma: client,
114
+ handlers: [handler],
115
+ handlerContext: createInboxTenancyContext({
116
+ context,
117
+ tenancy,
118
+ global: "shared",
119
+ }),
120
+ worker: { enabled: false, concurrency: 1, maxAttempts: 1 },
121
+ };
122
+ },
123
+ }),
124
+ ],
125
+ })
126
+ class AppModule {}
127
+ app = await NestFactory.createApplicationContext(AppModule, {
128
+ logger: ["error", "warn"],
129
+ abortOnError: false,
130
+ });
131
+ const inbox = app.get(InboxService);
132
+ const tenancy = app.get(TenancyService);
133
+ const runId = randomUUID();
134
+ const tenantA = `${runId}-a`;
135
+ const tenantB = `${runId}-b`;
136
+ const accept = (scope: InboxScope, targetTenant: string) =>
137
+ inbox.accept({
138
+ scope,
139
+ source: "trusted-fixture-route",
140
+ consumer,
141
+ messageId: randomUUID(),
142
+ eventType,
143
+ schemaVersion: 1,
144
+ payload: { targetTenant },
145
+ });
146
+ // Scope comes from trusted routing. A producer payload cannot choose its scope.
147
+ const accepted = await Promise.all([
148
+ accept({ kind: "tenant", id: tenantA }, tenantA),
149
+ accept({ kind: "tenant", id: tenantB }, tenantB),
150
+ accept({ kind: "tenant", id: tenantA }, tenantB),
151
+ accept({ kind: "global" }, ""),
152
+ ]);
153
+ for (let i = 0; i < accepted.length; i++) await inbox.runOnce();
154
+ const results = await Promise.all(
155
+ accepted.map(async (receipt) => {
156
+ const result = await admin.query<{ status: string }>(
157
+ "SELECT status FROM public.nestarc_inbox WHERE id=$1",
158
+ [receipt.id],
159
+ );
160
+ return result.rows[0].status;
161
+ }),
162
+ );
163
+ assert.deepEqual(results, ["COMPLETED", "COMPLETED", "DEAD", "COMPLETED"]);
164
+ const rejected = await admin.query(
165
+ "SELECT * FROM public.example_tenancy_records WHERE inbox_id=$1",
166
+ [accepted[2].id],
167
+ );
168
+ assert.equal(rejected.rowCount, 0);
169
+ assert.equal(tenancy.getCurrentTenant(), null);
170
+ const [setting] = await client.$queryRawUnsafe<
171
+ Array<{ tenant: string | null }>
172
+ >(
173
+ "SELECT NULLIF(current_setting('app.current_tenant', true), '') AS tenant",
174
+ );
175
+ assert.equal(setting.tenant, null);
176
+ const [permissions] = await client.$queryRawUnsafe<
177
+ Array<{ rolsuper: boolean; rolbypassrls: boolean }>
178
+ >("SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname=current_user");
179
+ assert.equal(permissions.rolsuper, false);
180
+ assert.equal(permissions.rolbypassrls, false);
181
+ console.log(
182
+ JSON.stringify({
183
+ tenantsCompleted: 2,
184
+ crossTenantWrite: "rejected",
185
+ globalShared: "completed",
186
+ contextAfterProcessing: null,
187
+ }),
188
+ );
189
+ } finally {
190
+ try {
191
+ await app?.close();
192
+ } finally {
193
+ try {
194
+ await prisma?.$disconnect();
195
+ } finally {
196
+ try {
197
+ if (roleCreated)
198
+ await admin.query(`DROP OWNED BY ${role}; DROP ROLE ${role}`);
199
+ } finally {
200
+ await admin.end();
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ void main().catch((error: unknown) => {
208
+ console.error(error);
209
+ process.exitCode = 1;
210
+ });