@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.
- package/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +295 -2
- package/dist/contracts/errors.d.ts +34 -0
- package/dist/contracts/errors.js +73 -0
- package/dist/contracts/errors.js.map +1 -0
- package/dist/contracts/types.d.ts +174 -0
- package/dist/contracts/types.js +3 -0
- package/dist/contracts/types.js.map +1 -0
- package/dist/inbox-admin.service.d.ts +44 -0
- package/dist/inbox-admin.service.js +102 -0
- package/dist/inbox-admin.service.js.map +1 -0
- package/dist/inbox.module.d.ts +13 -0
- package/dist/inbox.module.js +59 -0
- package/dist/inbox.module.js.map +1 -0
- package/dist/inbox.service.d.ts +22 -0
- package/dist/inbox.service.js +74 -0
- package/dist/inbox.service.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/tenancy.d.ts +25 -0
- package/dist/integrations/tenancy.js +56 -0
- package/dist/integrations/tenancy.js.map +1 -0
- package/dist/internal/handler-context.d.ts +3 -0
- package/dist/internal/handler-context.js +39 -0
- package/dist/internal/handler-context.js.map +1 -0
- package/dist/internal/options.d.ts +5 -0
- package/dist/internal/options.js +86 -0
- package/dist/internal/options.js.map +1 -0
- package/dist/internal/reliability-core/abort.d.ts +2 -0
- package/dist/internal/reliability-core/abort.js +21 -0
- package/dist/internal/reliability-core/abort.js.map +1 -0
- package/dist/internal/reliability-core/index.d.ts +3 -0
- package/dist/internal/reliability-core/index.js +14 -0
- package/dist/internal/reliability-core/index.js.map +1 -0
- package/dist/internal/reliability-core/json.d.ts +13 -0
- package/dist/internal/reliability-core/json.js +125 -0
- package/dist/internal/reliability-core/json.js.map +1 -0
- package/dist/internal/reliability-core/retry.d.ts +8 -0
- package/dist/internal/reliability-core/retry.js +26 -0
- package/dist/internal/reliability-core/retry.js.map +1 -0
- package/dist/internal/validation.d.ts +6 -0
- package/dist/internal/validation.js +123 -0
- package/dist/internal/validation.js.map +1 -0
- package/dist/storage/prisma/prisma-inbox.storage.d.ts +30 -0
- package/dist/storage/prisma/prisma-inbox.storage.js +369 -0
- package/dist/storage/prisma/prisma-inbox.storage.js.map +1 -0
- package/dist/worker/inbox-worker.d.ts +31 -0
- package/dist/worker/inbox-worker.js +343 -0
- package/dist/worker/inbox-worker.js.map +1 -0
- package/docs/adr/0001-durable-inbox.md +40 -0
- package/docs/benchmarks/2026-10-27-local.md +31 -0
- package/docs/benchmarks/2026-10-28-preview1-local.md +87 -0
- package/docs/benchmarks/2026-10-28-preview1-run1.json +120 -0
- package/docs/benchmarks/2026-10-28-preview1-run2.json +120 -0
- package/docs/benchmarks/2026-10-28-preview1-run3.json +120 -0
- package/docs/implementation-roadmap.md +127 -0
- package/docs/package-plan.md +250 -0
- package/docs/releasing.md +83 -0
- package/docs/usage.md +266 -0
- package/docs/validation.md +139 -0
- package/examples/README.md +124 -0
- package/examples/compose.yaml +26 -0
- package/examples/package.json +42 -0
- package/examples/prisma/schema.prisma +29 -0
- package/examples/prisma.config.ts +10 -0
- package/examples/scripts/prepare-db.cjs +45 -0
- package/examples/scripts/smoke.cjs +37 -0
- package/examples/scripts/with-brokers.cjs +249 -0
- package/examples/scripts/with-rabbitmq.cjs +131 -0
- package/examples/sql/application.sql +14 -0
- package/examples/sql/tenancy.sql +26 -0
- package/examples/src/broker-common.ts +79 -0
- package/examples/src/bullmq-ingress/main.ts +165 -0
- package/examples/src/common.ts +112 -0
- package/examples/src/idempotency-ingress/main.ts +211 -0
- package/examples/src/kafka-ingress/main.ts +209 -0
- package/examples/src/outbox-to-inbox/main.ts +198 -0
- package/examples/src/rabbitmq-ingress/main.ts +203 -0
- package/examples/src/sqs-ingress/main.ts +273 -0
- package/examples/src/tenancy/main.ts +210 -0
- package/examples/src/webhook-ingress/main.ts +172 -0
- package/examples/tsconfig.json +16 -0
- package/package.json +90 -4
- package/src/sql/create-inbox-table.sql +100 -0
- package/src/sql/migrate-v1-to-v2.sql +53 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const { spawn } = require("node:child_process");
|
|
2
|
+
const { randomBytes } = require("node:crypto");
|
|
3
|
+
const { resolve } = require("node:path");
|
|
4
|
+
const { setTimeout } = require("node:timers/promises");
|
|
5
|
+
|
|
6
|
+
const root = resolve(__dirname, "../..");
|
|
7
|
+
let containerId;
|
|
8
|
+
let child;
|
|
9
|
+
let interrupted;
|
|
10
|
+
|
|
11
|
+
function capture(command, args) {
|
|
12
|
+
return new Promise((done, reject) => {
|
|
13
|
+
const process = spawn(command, args, { cwd: root });
|
|
14
|
+
let output = "";
|
|
15
|
+
let error = "";
|
|
16
|
+
process.stdout.on("data", (chunk) => {
|
|
17
|
+
output += chunk;
|
|
18
|
+
});
|
|
19
|
+
process.stderr.on("data", (chunk) => {
|
|
20
|
+
error += chunk;
|
|
21
|
+
});
|
|
22
|
+
process.on("error", reject);
|
|
23
|
+
process.on("close", (code) =>
|
|
24
|
+
code === 0
|
|
25
|
+
? done(output.trim())
|
|
26
|
+
: reject(new Error(`${command} failed: ${error || output}`)),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
32
|
+
process.on(signal, () => {
|
|
33
|
+
interrupted = signal;
|
|
34
|
+
child?.kill(signal);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function withRabbitmq(run) {
|
|
39
|
+
try {
|
|
40
|
+
containerId = await capture("docker", [
|
|
41
|
+
"create",
|
|
42
|
+
"--name",
|
|
43
|
+
`nestarc-inbox-rabbitmq-${randomBytes(8).toString("hex")}`,
|
|
44
|
+
"--label",
|
|
45
|
+
"dev.nestarc.inbox.fixture=true",
|
|
46
|
+
"--publish",
|
|
47
|
+
"127.0.0.1::5672",
|
|
48
|
+
"--tmpfs",
|
|
49
|
+
"/var/lib/rabbitmq",
|
|
50
|
+
"rabbitmq:4-alpine",
|
|
51
|
+
]);
|
|
52
|
+
if (!/^[a-f0-9]{64}$/.test(containerId))
|
|
53
|
+
throw new Error("Unexpected fixture container identity");
|
|
54
|
+
await capture("docker", ["start", containerId]);
|
|
55
|
+
const deadline = Date.now() + 60_000;
|
|
56
|
+
let ready = false;
|
|
57
|
+
let lastReadinessError;
|
|
58
|
+
while (Date.now() < deadline && !interrupted) {
|
|
59
|
+
try {
|
|
60
|
+
await capture("docker", [
|
|
61
|
+
"exec",
|
|
62
|
+
"--user",
|
|
63
|
+
"rabbitmq",
|
|
64
|
+
containerId,
|
|
65
|
+
"rabbitmq-diagnostics",
|
|
66
|
+
"-q",
|
|
67
|
+
"check_port_connectivity",
|
|
68
|
+
]);
|
|
69
|
+
ready = true;
|
|
70
|
+
break;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
lastReadinessError = error;
|
|
73
|
+
await setTimeout(500);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!ready) {
|
|
77
|
+
console.error(
|
|
78
|
+
await capture("docker", ["logs", "--tail", "150", containerId]),
|
|
79
|
+
);
|
|
80
|
+
throw new Error(
|
|
81
|
+
`RabbitMQ fixture did not become ready: ${String(lastReadinessError)}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const binding = await capture("docker", ["port", containerId, "5672/tcp"]);
|
|
85
|
+
const match = /^127\.0\.0\.1:(\d+)$/.exec(binding);
|
|
86
|
+
if (!match) throw new Error("RabbitMQ must be bound to one loopback port");
|
|
87
|
+
console.log(`Disposable RabbitMQ fixture ready on 127.0.0.1:${match[1]}.`);
|
|
88
|
+
return await run(`amqp://guest:guest@127.0.0.1:${match[1]}`);
|
|
89
|
+
} finally {
|
|
90
|
+
if (containerId && /^[a-f0-9]{64}$/.test(containerId)) {
|
|
91
|
+
await capture("docker", ["rm", "--force", "--volumes", containerId]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function main() {
|
|
97
|
+
process.exitCode = await withRabbitmq(
|
|
98
|
+
(url) =>
|
|
99
|
+
new Promise((done, reject) => {
|
|
100
|
+
child = spawn(
|
|
101
|
+
process.execPath,
|
|
102
|
+
[
|
|
103
|
+
"scripts/with-test-db.mjs",
|
|
104
|
+
process.execPath,
|
|
105
|
+
"examples/scripts/smoke.cjs",
|
|
106
|
+
"--rabbitmq",
|
|
107
|
+
],
|
|
108
|
+
{
|
|
109
|
+
cwd: root,
|
|
110
|
+
env: {
|
|
111
|
+
...process.env,
|
|
112
|
+
INBOX_EXAMPLE_AMQP_URL: url,
|
|
113
|
+
},
|
|
114
|
+
stdio: "inherit",
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
child.on("error", reject);
|
|
118
|
+
child.on("close", (code) => {
|
|
119
|
+
child = undefined;
|
|
120
|
+
done(code ?? 1);
|
|
121
|
+
});
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
module.exports = { withRabbitmq };
|
|
126
|
+
if (require.main === module) {
|
|
127
|
+
main().catch((error) => {
|
|
128
|
+
console.error(error.message);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS inbox_example_orders (
|
|
2
|
+
id text PRIMARY KEY,
|
|
3
|
+
tenant_id text NOT NULL
|
|
4
|
+
);
|
|
5
|
+
CREATE TABLE IF NOT EXISTS inbox_example_projections (
|
|
6
|
+
tenant_id text NOT NULL,
|
|
7
|
+
order_id text NOT NULL,
|
|
8
|
+
PRIMARY KEY (tenant_id, order_id)
|
|
9
|
+
);
|
|
10
|
+
CREATE TABLE IF NOT EXISTS inbox_example_receipts (
|
|
11
|
+
tenant_id text NOT NULL,
|
|
12
|
+
order_id text NOT NULL,
|
|
13
|
+
PRIMARY KEY (tenant_id, order_id)
|
|
14
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- Apply only to the dedicated local example database. The runtime role below
|
|
2
|
+
-- is provisioned separately by the runnable example and never owns these tables.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS public.example_tenancy_records (
|
|
4
|
+
id uuid PRIMARY KEY,
|
|
5
|
+
tenant_id text NOT NULL,
|
|
6
|
+
inbox_id uuid NOT NULL,
|
|
7
|
+
note text NOT NULL
|
|
8
|
+
);
|
|
9
|
+
CREATE INDEX IF NOT EXISTS example_tenancy_records_tenant_idx
|
|
10
|
+
ON public.example_tenancy_records (tenant_id);
|
|
11
|
+
ALTER TABLE public.example_tenancy_records ENABLE ROW LEVEL SECURITY;
|
|
12
|
+
ALTER TABLE public.example_tenancy_records FORCE ROW LEVEL SECURITY;
|
|
13
|
+
DROP POLICY IF EXISTS tenant_isolation ON public.example_tenancy_records;
|
|
14
|
+
CREATE POLICY tenant_isolation ON public.example_tenancy_records
|
|
15
|
+
USING (tenant_id = NULLIF(current_setting('app.current_tenant', true), ''))
|
|
16
|
+
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant', true), ''));
|
|
17
|
+
DROP POLICY IF EXISTS tenant_required ON public.example_tenancy_records;
|
|
18
|
+
CREATE POLICY tenant_required ON public.example_tenancy_records AS RESTRICTIVE
|
|
19
|
+
USING (NULLIF(current_setting('app.current_tenant', true), '') IS NOT NULL)
|
|
20
|
+
WITH CHECK (NULLIF(current_setting('app.current_tenant', true), '') IS NOT NULL);
|
|
21
|
+
|
|
22
|
+
-- Intentionally shared records have their own table and permissions.
|
|
23
|
+
CREATE TABLE IF NOT EXISTS public.example_tenancy_shared (
|
|
24
|
+
id uuid PRIMARY KEY,
|
|
25
|
+
inbox_id uuid NOT NULL
|
|
26
|
+
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { Module } from "@nestjs/common";
|
|
3
|
+
import { NestFactory } from "@nestjs/core";
|
|
4
|
+
import { InboxModule, InboxValidationError } from "@nestarc/inbox";
|
|
5
|
+
import { createPrisma, projectionHandler } from "./common";
|
|
6
|
+
|
|
7
|
+
export type BrokerEvent = { eventId: string; orderId: string };
|
|
8
|
+
|
|
9
|
+
/** The producer's event ID survives transport redelivery and republishing. */
|
|
10
|
+
export function parseBrokerEvent(value: unknown): BrokerEvent {
|
|
11
|
+
if (
|
|
12
|
+
!value ||
|
|
13
|
+
typeof value !== "object" ||
|
|
14
|
+
Array.isArray(value) ||
|
|
15
|
+
typeof (value as BrokerEvent).eventId !== "string" ||
|
|
16
|
+
!(value as BrokerEvent).eventId.trim() ||
|
|
17
|
+
Buffer.byteLength((value as BrokerEvent).eventId) > 256 ||
|
|
18
|
+
typeof (value as BrokerEvent).orderId !== "string" ||
|
|
19
|
+
!(value as BrokerEvent).orderId.trim() ||
|
|
20
|
+
Buffer.byteLength((value as BrokerEvent).orderId) > 256
|
|
21
|
+
)
|
|
22
|
+
throw new InboxValidationError("Expected an eventId and orderId.");
|
|
23
|
+
return value as BrokerEvent;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Local smoke fixture: fail the first handler attempt, then let Inbox retry it. */
|
|
27
|
+
export async function startBrokerInbox(consumer: string, eventType: string) {
|
|
28
|
+
const prisma = createPrisma();
|
|
29
|
+
const projection = projectionHandler(consumer, eventType);
|
|
30
|
+
let retries = 0;
|
|
31
|
+
@Module({
|
|
32
|
+
imports: [
|
|
33
|
+
InboxModule.forRoot({
|
|
34
|
+
prisma,
|
|
35
|
+
handlers: [
|
|
36
|
+
{
|
|
37
|
+
...projection,
|
|
38
|
+
async handle(payload, context) {
|
|
39
|
+
if (context.attempt === 1)
|
|
40
|
+
throw new Error("Injected first handler failure");
|
|
41
|
+
await projection.handle(payload, context);
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
worker: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
pollIntervalMs: 25,
|
|
48
|
+
concurrency: 2,
|
|
49
|
+
retryBaseMs: 10,
|
|
50
|
+
retryMaxMs: 20,
|
|
51
|
+
},
|
|
52
|
+
observer: (event) => {
|
|
53
|
+
if (event.type === "retry") retries++;
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
],
|
|
57
|
+
})
|
|
58
|
+
class AppModule {}
|
|
59
|
+
try {
|
|
60
|
+
const app = await NestFactory.createApplicationContext(AppModule, {
|
|
61
|
+
logger: ["error", "warn"],
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
app,
|
|
65
|
+
prisma,
|
|
66
|
+
retries: () => retries,
|
|
67
|
+
async close() {
|
|
68
|
+
try {
|
|
69
|
+
await app.close();
|
|
70
|
+
} finally {
|
|
71
|
+
await prisma.$disconnect();
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
} catch (error) {
|
|
76
|
+
await prisma.$disconnect();
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { Queue, Worker, UnrecoverableError } from "bullmq";
|
|
4
|
+
import {
|
|
5
|
+
InboxConflictError,
|
|
6
|
+
InboxService,
|
|
7
|
+
InboxValidationError,
|
|
8
|
+
} from "@nestarc/inbox";
|
|
9
|
+
import { tenantId, waitUntil } from "../common";
|
|
10
|
+
import {
|
|
11
|
+
parseBrokerEvent,
|
|
12
|
+
startBrokerInbox,
|
|
13
|
+
type BrokerEvent,
|
|
14
|
+
} from "../broker-common";
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
const value = process.env.INBOX_EXAMPLE_REDIS_URL;
|
|
18
|
+
if (!value)
|
|
19
|
+
throw new Error("Set INBOX_EXAMPLE_REDIS_URL to the local Redis fixture.");
|
|
20
|
+
const url = new URL(value);
|
|
21
|
+
if (
|
|
22
|
+
url.protocol !== "redis:" ||
|
|
23
|
+
url.hostname !== "127.0.0.1" ||
|
|
24
|
+
url.username ||
|
|
25
|
+
url.password
|
|
26
|
+
)
|
|
27
|
+
throw new Error(
|
|
28
|
+
"This example uses only the unauthenticated local Redis fixture.",
|
|
29
|
+
);
|
|
30
|
+
const connection = {
|
|
31
|
+
host: url.hostname,
|
|
32
|
+
port: Number(url.port || 6379),
|
|
33
|
+
maxRetriesPerRequest: null,
|
|
34
|
+
connectTimeout: 5_000,
|
|
35
|
+
};
|
|
36
|
+
const name = `inbox-example-${randomUUID()}`;
|
|
37
|
+
let callbackError: unknown;
|
|
38
|
+
const queue = new Queue<BrokerEvent>(name, {
|
|
39
|
+
connection: { ...connection, maxRetriesPerRequest: 1 },
|
|
40
|
+
defaultJobOptions: {
|
|
41
|
+
attempts: 3,
|
|
42
|
+
backoff: { type: "exponential", delay: 100 },
|
|
43
|
+
removeOnComplete: false,
|
|
44
|
+
removeOnFail: false,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
queue.on("error", (error) => {
|
|
48
|
+
callbackError = error;
|
|
49
|
+
});
|
|
50
|
+
const fixture = await startBrokerInbox(
|
|
51
|
+
"example.bullmq.v1",
|
|
52
|
+
"order.created",
|
|
53
|
+
).catch(async (error: unknown) => {
|
|
54
|
+
await queue.close();
|
|
55
|
+
throw error;
|
|
56
|
+
});
|
|
57
|
+
const inbox = fixture.app.get(InboxService);
|
|
58
|
+
let transportRetries = 0;
|
|
59
|
+
let injectAcceptanceFailure = true;
|
|
60
|
+
const worker = new Worker<BrokerEvent>(
|
|
61
|
+
name,
|
|
62
|
+
async (job) => {
|
|
63
|
+
try {
|
|
64
|
+
const event = parseBrokerEvent(job.data);
|
|
65
|
+
// Fixture-only failure: BullMQ retries acceptance, not the business handler.
|
|
66
|
+
if (injectAcceptanceFailure) {
|
|
67
|
+
injectAcceptanceFailure = false;
|
|
68
|
+
transportRetries++;
|
|
69
|
+
throw new Error("Injected acceptance failure");
|
|
70
|
+
}
|
|
71
|
+
const receipt = await inbox.accept({
|
|
72
|
+
scope: { kind: "tenant", id: tenantId },
|
|
73
|
+
source: "example-bullmq-orders",
|
|
74
|
+
consumer: "example.bullmq.v1",
|
|
75
|
+
messageId: event.eventId,
|
|
76
|
+
eventType: "order.created",
|
|
77
|
+
schemaVersion: 1,
|
|
78
|
+
payload: { orderId: event.orderId },
|
|
79
|
+
});
|
|
80
|
+
// Return only after the Inbox transaction commits. This completes the queue job.
|
|
81
|
+
return receipt.kind;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (
|
|
84
|
+
error instanceof InboxValidationError ||
|
|
85
|
+
error instanceof InboxConflictError
|
|
86
|
+
)
|
|
87
|
+
throw new UnrecoverableError(
|
|
88
|
+
"Permanent ingress rejection; inspect the retained failed job.",
|
|
89
|
+
);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
{ connection, concurrency: 1, maxStalledCount: 1 },
|
|
94
|
+
);
|
|
95
|
+
worker.on("error", (error) => {
|
|
96
|
+
callbackError = error;
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
await worker.waitUntilReady();
|
|
100
|
+
const orderId = randomUUID();
|
|
101
|
+
const eventId = randomUUID();
|
|
102
|
+
// Different job IDs deliberately prove deduplication belongs to Inbox.
|
|
103
|
+
const jobs = await queue.addBulk([
|
|
104
|
+
{
|
|
105
|
+
name: "order",
|
|
106
|
+
data: { eventId, orderId },
|
|
107
|
+
opts: { jobId: randomUUID() },
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "order",
|
|
111
|
+
data: { eventId, orderId },
|
|
112
|
+
opts: { jobId: randomUUID() },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "invalid",
|
|
116
|
+
data: { eventId: "", orderId: "" },
|
|
117
|
+
opts: { jobId: randomUUID() },
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
await waitUntil(async () => {
|
|
121
|
+
if (callbackError) throw callbackError;
|
|
122
|
+
return (
|
|
123
|
+
(await jobs[0]!.getState()) === "completed" &&
|
|
124
|
+
(await jobs[1]!.getState()) === "completed" &&
|
|
125
|
+
(await jobs[2]!.getState()) === "failed" &&
|
|
126
|
+
(await fixture.prisma.exampleProjection.count({
|
|
127
|
+
where: { orderId, tenantId },
|
|
128
|
+
})) === 1
|
|
129
|
+
);
|
|
130
|
+
}, "BullMQ acceptance retries, permanent failed jobs, and one projection");
|
|
131
|
+
const failed = await queue.getJob(jobs[2]!.id!);
|
|
132
|
+
assert.equal(failed?.attemptsMade, 1);
|
|
133
|
+
assert.equal(transportRetries, 1);
|
|
134
|
+
assert.equal(fixture.retries(), 1);
|
|
135
|
+
console.log(
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
broker: "BullMQ / Redis",
|
|
138
|
+
completedJobs: 2,
|
|
139
|
+
quarantinedJobs: 1,
|
|
140
|
+
transportRetries,
|
|
141
|
+
handlerRetries: fixture.retries(),
|
|
142
|
+
projections: 1,
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
} finally {
|
|
146
|
+
try {
|
|
147
|
+
await worker.close();
|
|
148
|
+
} finally {
|
|
149
|
+
try {
|
|
150
|
+
await queue.obliterate({ force: true });
|
|
151
|
+
} finally {
|
|
152
|
+
try {
|
|
153
|
+
await queue.close();
|
|
154
|
+
} finally {
|
|
155
|
+
await fixture.close();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
void main().catch((error: unknown) => {
|
|
163
|
+
console.error(error);
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { Module } from "@nestjs/common";
|
|
3
|
+
import { NestFactory } from "@nestjs/core";
|
|
4
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
5
|
+
import {
|
|
6
|
+
InboxModule,
|
|
7
|
+
InboxPermanentError,
|
|
8
|
+
type InboxHandler,
|
|
9
|
+
} from "@nestarc/inbox";
|
|
10
|
+
import { PrismaClient, type Prisma } from "./generated/prisma/client";
|
|
11
|
+
import { setTimeout } from "node:timers/promises";
|
|
12
|
+
|
|
13
|
+
export const tenantId = "example-tenant";
|
|
14
|
+
export type OrderPayload = { orderId: string };
|
|
15
|
+
|
|
16
|
+
export function exampleDatabaseUrl(): string {
|
|
17
|
+
const value = process.env.INBOX_EXAMPLE_DATABASE_URL;
|
|
18
|
+
if (!value)
|
|
19
|
+
throw new Error(
|
|
20
|
+
"Set INBOX_EXAMPLE_DATABASE_URL to the dedicated local fixture.",
|
|
21
|
+
);
|
|
22
|
+
const url = new URL(value);
|
|
23
|
+
const isolatedTest =
|
|
24
|
+
process.env.INBOX_TEST_DATABASE === "1" &&
|
|
25
|
+
process.env.INBOX_TEST_DATABASE_URL === value &&
|
|
26
|
+
process.env.INBOX_TEST_DATABASE_NAME === url.pathname.slice(1) &&
|
|
27
|
+
/^\/nestarc_inbox_test_[a-f0-9]{16}$/.test(url.pathname) &&
|
|
28
|
+
url.username === "inbox_test";
|
|
29
|
+
if (
|
|
30
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ||
|
|
31
|
+
(!/^\/inbox_examples(?:_[a-zA-Z0-9_]+)?$/.test(url.pathname) &&
|
|
32
|
+
!isolatedTest)
|
|
33
|
+
) {
|
|
34
|
+
throw new Error("Examples require a local inbox_examples database.");
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createPrisma(): PrismaClient {
|
|
40
|
+
return new PrismaClient({
|
|
41
|
+
adapter: new PrismaPg({ connectionString: exampleDatabaseUrl(), max: 12 }),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function projectionHandler(
|
|
46
|
+
consumer: string,
|
|
47
|
+
eventType: string,
|
|
48
|
+
): InboxHandler<OrderPayload, Prisma.TransactionClient> {
|
|
49
|
+
return {
|
|
50
|
+
consumer,
|
|
51
|
+
eventType,
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
validate: (payload) =>
|
|
54
|
+
!!payload &&
|
|
55
|
+
typeof payload === "object" &&
|
|
56
|
+
!Array.isArray(payload) &&
|
|
57
|
+
typeof payload.orderId === "string" &&
|
|
58
|
+
payload.orderId.length > 0,
|
|
59
|
+
async handle(payload, ctx) {
|
|
60
|
+
if (ctx.scope.kind !== "tenant") throw new InboxPermanentError();
|
|
61
|
+
await ctx.tx.exampleProjection.create({
|
|
62
|
+
data: { tenantId: ctx.scope.id, orderId: payload.orderId },
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function startInbox(consumer: string, eventType: string) {
|
|
69
|
+
const prisma = createPrisma();
|
|
70
|
+
@Module({
|
|
71
|
+
imports: [
|
|
72
|
+
InboxModule.forRoot({
|
|
73
|
+
prisma,
|
|
74
|
+
handlers: [projectionHandler(consumer, eventType)],
|
|
75
|
+
worker: { enabled: true, pollIntervalMs: 25, concurrency: 2 },
|
|
76
|
+
}),
|
|
77
|
+
],
|
|
78
|
+
})
|
|
79
|
+
class AppModule {}
|
|
80
|
+
try {
|
|
81
|
+
const app = await NestFactory.createApplicationContext(AppModule, {
|
|
82
|
+
logger: ["error", "warn"],
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
app,
|
|
86
|
+
prisma,
|
|
87
|
+
async close() {
|
|
88
|
+
try {
|
|
89
|
+
await app.close();
|
|
90
|
+
} finally {
|
|
91
|
+
await prisma.$disconnect();
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
await prisma.$disconnect();
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function waitUntil(
|
|
102
|
+
check: () => Promise<boolean>,
|
|
103
|
+
label: string,
|
|
104
|
+
timeoutMs = 15_000,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const deadline = Date.now() + timeoutMs;
|
|
107
|
+
while (Date.now() < deadline) {
|
|
108
|
+
if (await check()) return;
|
|
109
|
+
await setTimeout(25);
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
112
|
+
}
|