@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,124 @@
|
|
|
1
|
+
# Runnable inbox examples
|
|
2
|
+
|
|
3
|
+
These examples import the installed `@nestarc/inbox` package and use a real PostgreSQL database. The outbox and webhook examples run once, assert their results, and exit. The RabbitMQ example additionally requires the local broker fixture.
|
|
4
|
+
|
|
5
|
+
Use Node 22.23.4+ or 24.22.1+ for the full examples project because it includes `@nestarc/tenancy` 0.16.2. The package's full `npm run test:examples` command builds a tarball, installs these examples in an isolated directory, and executes every example against temporary fixtures.
|
|
6
|
+
|
|
7
|
+
## Install the package
|
|
8
|
+
|
|
9
|
+
Run these commands from `examples/`:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install --no-save @nestarc/inbox@0.1.0
|
|
13
|
+
npm run generate
|
|
14
|
+
npm run build
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
To run against a local source build instead, run `npm ci` and `npm pack` from the repository root. Then, from `examples/`, install the tarball and regenerate/build:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install --no-save ../nestarc-inbox-0.1.0.tgz
|
|
21
|
+
npm run generate
|
|
22
|
+
npm run build
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The automated test runners always install the tarball built from the checkout, so they exercise the exact candidate artifact. Prisma 7.10.1 uses the PostgreSQL driver adapter and the generated client in `src/generated/prisma`. Version 0.1.0 uses schema v2 and the runtime implementation validated under the local Preview.1 development version.
|
|
26
|
+
|
|
27
|
+
## Start a dedicated database
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
docker compose -p nestarc-inbox-examples up -d --wait postgres
|
|
31
|
+
export INBOX_EXAMPLE_DATABASE_URL='postgresql://example:example@127.0.0.1:55434/inbox_examples'
|
|
32
|
+
npm run db:prepare
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`db:prepare` applies the installed inbox and outbox SQL assets plus the example application tables. It only accepts a loopback database named `inbox_examples` or `inbox_examples_<suffix>`. It does not run Prisma schema synchronization: the package tables are owned by their SQL migrations and are absent from the example Prisma schema.
|
|
36
|
+
|
|
37
|
+
The Compose database uses temporary storage. Stopping the container discards this fixture. Do not use these commands against an application database.
|
|
38
|
+
|
|
39
|
+
## Outbox to inbox and downstream delivery
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
npm run outbox
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The application commits an order and an outbox event together. A local outbox listener durably accepts that event into Inbox. Its Inbox handler creates a projection and another outbox event in the same transaction. A second Inbox consumer stores the downstream receipt. Repeating the first input returns `duplicate`.
|
|
46
|
+
|
|
47
|
+
Expected stable output:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{ "projections": 1, "downstreamReceipts": 1, "duplicateReplay": "duplicate" }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
No email or external HTTP call is made. `OutboxEmitter` receives the exact transaction supplied by Inbox. Independent consumers have independent durable identities.
|
|
54
|
+
|
|
55
|
+
## RabbitMQ ingress
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
docker compose -p nestarc-inbox-examples up -d --wait rabbitmq
|
|
59
|
+
export INBOX_EXAMPLE_AMQP_URL='amqp://guest:guest@127.0.0.1:55672'
|
|
60
|
+
npm run rabbitmq
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The example uses `noAck: false`, prefetch 1, publisher confirms, and durable queues. It publishes two copies with the same stable `messageId` and verifies one projection. ACK follows `accept()` success. The broker delivery tag is never a deduplication key.
|
|
64
|
+
|
|
65
|
+
Malformed input and identity conflicts go to the run's dead-letter queue. Transient failures before acceptance enter a one-second TTL retry queue, up to three retries. A failed retry publish retains/requeues the original delivery after a pause. Once accepted, handler retries belong solely to Inbox. Each run creates uniquely named fixture queues so unrelated broker data is not changed.
|
|
66
|
+
|
|
67
|
+
The source and tenant come from trusted route configuration in this fixture. Production applications must authenticate the broker connection and map routes/accounts to tenants themselves.
|
|
68
|
+
|
|
69
|
+
## Signed webhook ingress
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
export INBOX_EXAMPLE_WEBHOOK_SECRET="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))")"
|
|
73
|
+
npm run webhook
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The example starts an HTTP server on an ephemeral loopback port and sends local fixture requests. It signs raw bytes with a timestamp, checks the HMAC using a constant-time comparison, rejects stale timestamps, and limits the request body. The server maps the authenticated account to a tenant; it never accepts a tenant chosen by the payload.
|
|
77
|
+
|
|
78
|
+
It asserts that an invalid signature returns 401, the first valid event returns 202, a duplicate returns 200, and the projection appears once. HTTP success means durable acceptance. It does not mean the business handler has already completed.
|
|
79
|
+
|
|
80
|
+
This HMAC convention is a local example. For a real provider, use that provider's documented raw-body signature and account verification protocol before calling Inbox.
|
|
81
|
+
|
|
82
|
+
## Cleanup
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
docker compose -p nestarc-inbox-examples down
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The examples close the Nest application before disconnecting the application-owned Prisma client. Database rows can remain across repeated runs while the fixture is running; every run uses fresh event and business IDs.
|
|
89
|
+
|
|
90
|
+
## Kafka, SQS-compatible, and BullMQ ingress
|
|
91
|
+
|
|
92
|
+
The repository runner supplies isolated Redpanda (Kafka protocol), Redis, and ElasticMQ (SQS protocol) containers, bound to loopback ports:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
# After installing Inbox in examples/, run from the repository root:
|
|
96
|
+
node examples/scripts/with-brokers.cjs
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
It also creates a disposable PostgreSQL database. Each ingress publishes distinct transport deliveries with the same stable producer event ID, injects one pre-acceptance storage failure and one handler failure, checks permanent-input rejection, and asserts one business projection. Only containers created by the runner are removed. The pinned fixtures are Redpanda v26.2.6, Redis 7.4.2, and ElasticMQ 1.7.1. ElasticMQ validates SQS API composition locally; this does not certify AWS IAM, service limits, network failures, or production SQS behavior.
|
|
100
|
+
|
|
101
|
+
| Example | Acknowledgment boundary | Failure handling |
|
|
102
|
+
| ---------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
103
|
+
| `npm run kafka` | Commit the next offset only after durable acceptance or confirmed dead-letter publish; process each partition sequentially | Bounded acceptance retries, then confirmed dead-letter publishing before offset advancement; a failed dead-letter publish leaves the offset uncommitted |
|
|
104
|
+
| `npm run sqs` | Delete the delivery only after durable acceptance or confirmed quarantine publish | Failed acceptance retains the message for redelivery; receive-count limit directs repeated failures to the fixture's dead-letter queue |
|
|
105
|
+
| `npm run bullmq` | Return from the queue processor only after durable acceptance | BullMQ attempts/backoff handle pre-acceptance failures; permanent input failures are retained as unrecoverable jobs |
|
|
106
|
+
|
|
107
|
+
The individual commands use `INBOX_EXAMPLE_KAFKA_BROKER`, `INBOX_EXAMPLE_SQS_ENDPOINT`, and `INBOX_EXAMPLE_REDIS_URL` provided by the runner. They require local fixture services. Kafka offsets, SQS receipt handles, and BullMQ job IDs are transport state, not the durable event identity. After acceptance, Inbox handles business retries independently of the transport.
|
|
108
|
+
|
|
109
|
+
## Tenancy and HTTP idempotency composition
|
|
110
|
+
|
|
111
|
+
`npm run tenancy` uses actual `TenancyModule`, `TenancyContext`, and `TenancyService` instances with `createInboxTenancyContext`. It creates a restricted PostgreSQL login role, applies the example RLS policies, and verifies tenant-specific reads/writes, cross-tenant rejection, transaction-local setting cleanup, and explicit global shared-table processing. Role creation is fixture setup performed with the dedicated example database owner; the handler role never bypasses RLS. Production policy/role provisioning remains an application migration responsibility.
|
|
112
|
+
|
|
113
|
+
`npm run idempotency` uses actual `@nestarc/idempotency/postgres` storage at a local HTTP boundary. It verifies cached response replay and conflicting content, waits for the HTTP response record's real expiry, then resends the stable event ID. Inbox still returns `duplicate`, the durable content conflict still receives 422, and the business projection remains singular. HTTP response retention and Inbox receipt retention serve different lifetimes.
|
|
114
|
+
|
|
115
|
+
## Automated fixture smoke checks
|
|
116
|
+
|
|
117
|
+
After installing Inbox in `examples/`, run from the repository root:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
node scripts/with-test-db.mjs node examples/scripts/smoke.cjs
|
|
121
|
+
node examples/scripts/with-rabbitmq.cjs
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The first command creates a disposable PostgreSQL instance and runs the outbox and webhook assertions. The second also creates a disposable RabbitMQ instance and runs all three. They remove only the containers they created. Missing Docker or an unhealthy fixture fails the command instead of skipping the checks. These repository runners are development tools; a copied package example can instead use the Compose setup above.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:16
|
|
4
|
+
environment:
|
|
5
|
+
POSTGRES_USER: example
|
|
6
|
+
POSTGRES_PASSWORD: example
|
|
7
|
+
POSTGRES_DB: inbox_examples
|
|
8
|
+
ports:
|
|
9
|
+
- "127.0.0.1:55434:5432"
|
|
10
|
+
tmpfs:
|
|
11
|
+
- /var/lib/postgresql/data
|
|
12
|
+
healthcheck:
|
|
13
|
+
test: ["CMD-SHELL", "pg_isready -U example -d inbox_examples"]
|
|
14
|
+
interval: 1s
|
|
15
|
+
timeout: 3s
|
|
16
|
+
retries: 30
|
|
17
|
+
rabbitmq:
|
|
18
|
+
image: rabbitmq:4-management
|
|
19
|
+
ports:
|
|
20
|
+
- "127.0.0.1:55672:5672"
|
|
21
|
+
- "127.0.0.1:55673:15672"
|
|
22
|
+
healthcheck:
|
|
23
|
+
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
|
24
|
+
interval: 3s
|
|
25
|
+
timeout: 5s
|
|
26
|
+
retries: 30
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nestarc-inbox-examples",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"generate": "prisma generate --config prisma.config.ts",
|
|
7
|
+
"db:prepare": "node scripts/prepare-db.cjs",
|
|
8
|
+
"build": "tsc -p tsconfig.json",
|
|
9
|
+
"outbox": "node dist/outbox-to-inbox/main.js",
|
|
10
|
+
"rabbitmq": "node dist/rabbitmq-ingress/main.js",
|
|
11
|
+
"webhook": "node dist/webhook-ingress/main.js",
|
|
12
|
+
"kafka": "node dist/kafka-ingress/main.js",
|
|
13
|
+
"sqs": "node dist/sqs-ingress/main.js",
|
|
14
|
+
"bullmq": "node dist/bullmq-ingress/main.js",
|
|
15
|
+
"tenancy": "node dist/tenancy/main.js",
|
|
16
|
+
"idempotency": "node dist/idempotency-ingress/main.js"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@nestarc/outbox": "0.4.0",
|
|
20
|
+
"@nestjs/common": "11.2.8",
|
|
21
|
+
"@nestjs/core": "11.2.8",
|
|
22
|
+
"@nestjs/schedule": "5.0.1",
|
|
23
|
+
"@prisma/adapter-pg": "7.10.1",
|
|
24
|
+
"@prisma/client": "7.10.1",
|
|
25
|
+
"amqplib": "0.10.9",
|
|
26
|
+
"pg": "8.23.1",
|
|
27
|
+
"reflect-metadata": "0.2.2",
|
|
28
|
+
"rxjs": "7.8.2",
|
|
29
|
+
"@nestarc/tenancy": "0.16.2",
|
|
30
|
+
"kafkajs": "2.2.4",
|
|
31
|
+
"bullmq": "5.81.5",
|
|
32
|
+
"@aws-sdk/client-sqs": "3.1158.0",
|
|
33
|
+
"@nestarc/idempotency": "1.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/amqplib": "0.10.8",
|
|
37
|
+
"@types/node": "24.19.2",
|
|
38
|
+
"prisma": "7.10.1",
|
|
39
|
+
"typescript": "5.9.3",
|
|
40
|
+
"@types/pg": "8.23.1"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
generator client {
|
|
2
|
+
provider = "prisma-client"
|
|
3
|
+
output = "../src/generated/prisma"
|
|
4
|
+
moduleFormat = "cjs"
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
datasource db {
|
|
8
|
+
provider = "postgresql"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
model ExampleOrder {
|
|
12
|
+
id String @id
|
|
13
|
+
tenantId String @map("tenant_id")
|
|
14
|
+
@@map("inbox_example_orders")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
model ExampleProjection {
|
|
18
|
+
tenantId String @map("tenant_id")
|
|
19
|
+
orderId String @map("order_id")
|
|
20
|
+
@@id([tenantId, orderId])
|
|
21
|
+
@@map("inbox_example_projections")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
model ExampleReceipt {
|
|
25
|
+
tenantId String @map("tenant_id")
|
|
26
|
+
orderId String @map("order_id")
|
|
27
|
+
@@id([tenantId, orderId])
|
|
28
|
+
@@map("inbox_example_receipts")
|
|
29
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const { readFileSync } = require("node:fs");
|
|
2
|
+
const { join } = require("node:path");
|
|
3
|
+
const { Client } = require("pg");
|
|
4
|
+
|
|
5
|
+
async function main() {
|
|
6
|
+
const connectionString = process.env.INBOX_EXAMPLE_DATABASE_URL;
|
|
7
|
+
if (!connectionString)
|
|
8
|
+
throw new Error(
|
|
9
|
+
"Set INBOX_EXAMPLE_DATABASE_URL to the dedicated local fixture.",
|
|
10
|
+
);
|
|
11
|
+
const url = new URL(connectionString);
|
|
12
|
+
const isolatedTest =
|
|
13
|
+
process.env.INBOX_TEST_DATABASE === "1" &&
|
|
14
|
+
process.env.INBOX_TEST_DATABASE_URL === connectionString &&
|
|
15
|
+
process.env.INBOX_TEST_DATABASE_NAME === url.pathname.slice(1) &&
|
|
16
|
+
/^\/nestarc_inbox_test_[a-f0-9]{16}$/.test(url.pathname) &&
|
|
17
|
+
url.username === "inbox_test";
|
|
18
|
+
if (
|
|
19
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ||
|
|
20
|
+
(!/^\/inbox_examples(?:_[a-zA-Z0-9_]+)?$/.test(url.pathname) &&
|
|
21
|
+
!isolatedTest)
|
|
22
|
+
) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"Examples only prepare a local database named inbox_examples or inbox_examples_<suffix>.",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const client = new Client({ connectionString });
|
|
28
|
+
await client.connect();
|
|
29
|
+
try {
|
|
30
|
+
for (const path of [
|
|
31
|
+
require.resolve("@nestarc/inbox/sql/create-inbox-table.sql"),
|
|
32
|
+
require.resolve("@nestarc/outbox/src/sql/create-outbox-table.sql"),
|
|
33
|
+
join(__dirname, "../sql/application.sql"),
|
|
34
|
+
]) {
|
|
35
|
+
await client.query(readFileSync(path, "utf8"));
|
|
36
|
+
}
|
|
37
|
+
console.log("Prepared the dedicated inbox example database.");
|
|
38
|
+
} finally {
|
|
39
|
+
await client.end();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
main().catch((error) => {
|
|
43
|
+
console.error(error.message);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { spawnSync } = require("node:child_process");
|
|
2
|
+
const { randomBytes } = require("node:crypto");
|
|
3
|
+
const { resolve } = require("node:path");
|
|
4
|
+
|
|
5
|
+
if (
|
|
6
|
+
process.env.INBOX_TEST_DATABASE !== "1" ||
|
|
7
|
+
!process.env.INBOX_TEST_DATABASE_URL
|
|
8
|
+
) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"Run through scripts/with-test-db.mjs so only a disposable database is used.",
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
const env = {
|
|
14
|
+
...process.env,
|
|
15
|
+
INBOX_EXAMPLE_DATABASE_URL: process.env.INBOX_TEST_DATABASE_URL,
|
|
16
|
+
INBOX_EXAMPLE_WEBHOOK_SECRET: randomBytes(32).toString("hex"),
|
|
17
|
+
};
|
|
18
|
+
const steps = [
|
|
19
|
+
"generate",
|
|
20
|
+
"build",
|
|
21
|
+
"db:prepare",
|
|
22
|
+
"outbox",
|
|
23
|
+
"webhook",
|
|
24
|
+
"tenancy",
|
|
25
|
+
"idempotency",
|
|
26
|
+
];
|
|
27
|
+
if (process.argv.includes("--rabbitmq")) steps.push("rabbitmq");
|
|
28
|
+
if (process.argv.includes("--brokers")) steps.push("kafka", "sqs", "bullmq");
|
|
29
|
+
for (const step of steps) {
|
|
30
|
+
const result = spawnSync("npm", ["run", step], {
|
|
31
|
+
cwd: resolve(__dirname, ".."),
|
|
32
|
+
env,
|
|
33
|
+
stdio: "inherit",
|
|
34
|
+
});
|
|
35
|
+
if (result.error) throw result.error;
|
|
36
|
+
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
37
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
const { spawn } = require("node:child_process");
|
|
2
|
+
const { randomBytes } = require("node:crypto");
|
|
3
|
+
const { createServer } = require("node:net");
|
|
4
|
+
const { resolve } = require("node:path");
|
|
5
|
+
const { setTimeout } = require("node:timers/promises");
|
|
6
|
+
|
|
7
|
+
const root = resolve(__dirname, "../..");
|
|
8
|
+
const images = {
|
|
9
|
+
kafka: "docker.redpanda.com/redpandadata/redpanda:v26.2.6",
|
|
10
|
+
redis: "redis:7.4.2-alpine",
|
|
11
|
+
sqs: "softwaremill/elasticmq-native:1.7.1",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function capture(command, args) {
|
|
15
|
+
return new Promise((done, reject) => {
|
|
16
|
+
const process = spawn(command, args, { cwd: root });
|
|
17
|
+
let output = "";
|
|
18
|
+
let error = "";
|
|
19
|
+
process.stdout.on("data", (chunk) => {
|
|
20
|
+
output += chunk;
|
|
21
|
+
});
|
|
22
|
+
process.stderr.on("data", (chunk) => {
|
|
23
|
+
error += chunk;
|
|
24
|
+
});
|
|
25
|
+
process.on("error", reject);
|
|
26
|
+
process.on("close", (code) =>
|
|
27
|
+
code === 0
|
|
28
|
+
? done(output.trim())
|
|
29
|
+
: reject(new Error(`${command} failed: ${error || output}`)),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function unusedLoopbackPort() {
|
|
35
|
+
const server = createServer();
|
|
36
|
+
return new Promise((done, reject) => {
|
|
37
|
+
server.on("error", reject);
|
|
38
|
+
server.listen(0, "127.0.0.1", () => {
|
|
39
|
+
const port = server.address().port;
|
|
40
|
+
server.close((error) => (error ? reject(error) : done(port)));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Each call owns and removes only the container IDs it creates. */
|
|
46
|
+
async function withBrokers(run) {
|
|
47
|
+
const containers = [];
|
|
48
|
+
let interrupted;
|
|
49
|
+
const onSignal = (signal) => {
|
|
50
|
+
interrupted = signal;
|
|
51
|
+
};
|
|
52
|
+
const onInt = () => onSignal("SIGINT");
|
|
53
|
+
const onTerm = () => onSignal("SIGTERM");
|
|
54
|
+
process.on("SIGINT", onInt);
|
|
55
|
+
process.on("SIGTERM", onTerm);
|
|
56
|
+
const checkInterrupted = () => {
|
|
57
|
+
if (interrupted) throw new Error(`Interrupted by ${interrupted}`);
|
|
58
|
+
};
|
|
59
|
+
async function create(kind, args) {
|
|
60
|
+
checkInterrupted();
|
|
61
|
+
const id = await capture("docker", [
|
|
62
|
+
"create",
|
|
63
|
+
"--name",
|
|
64
|
+
`nestarc-inbox-${kind}-${randomBytes(8).toString("hex")}`,
|
|
65
|
+
"--label",
|
|
66
|
+
"dev.nestarc.inbox.fixture=true",
|
|
67
|
+
...args,
|
|
68
|
+
]);
|
|
69
|
+
if (!/^[a-f0-9]{64}$/.test(id))
|
|
70
|
+
throw new Error("Unexpected fixture container identity");
|
|
71
|
+
containers.push(id);
|
|
72
|
+
checkInterrupted();
|
|
73
|
+
await capture("docker", ["start", id]);
|
|
74
|
+
return id;
|
|
75
|
+
}
|
|
76
|
+
async function binding(id, port) {
|
|
77
|
+
const result = await capture("docker", ["port", id, `${port}/tcp`]);
|
|
78
|
+
if (!/^127\.0\.0\.1:\d+$/.test(result))
|
|
79
|
+
throw new Error("Broker must have one loopback port binding");
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
async function ready(label, probe) {
|
|
83
|
+
const deadline = Date.now() + 60_000;
|
|
84
|
+
let lastError;
|
|
85
|
+
while (Date.now() < deadline) {
|
|
86
|
+
checkInterrupted();
|
|
87
|
+
try {
|
|
88
|
+
await probe();
|
|
89
|
+
return;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
lastError = error;
|
|
92
|
+
await setTimeout(300);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new Error(
|
|
96
|
+
`${label} fixture did not become ready: ${String(lastError)}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
await capture("docker", ["info", "--format", "{{.ServerVersion}}"]);
|
|
101
|
+
// Kafka metadata advertises the actual random host port. If another process
|
|
102
|
+
// acquires this port before Docker binds it, creation fails and cleans up.
|
|
103
|
+
const kafkaPort = await unusedLoopbackPort();
|
|
104
|
+
const kafkaId = await create("kafka", [
|
|
105
|
+
"--publish",
|
|
106
|
+
`127.0.0.1:${kafkaPort}:9092`,
|
|
107
|
+
images.kafka,
|
|
108
|
+
"redpanda",
|
|
109
|
+
"start",
|
|
110
|
+
"--mode",
|
|
111
|
+
"dev-container",
|
|
112
|
+
"--smp",
|
|
113
|
+
"1",
|
|
114
|
+
"--memory",
|
|
115
|
+
"1G",
|
|
116
|
+
"--reserve-memory",
|
|
117
|
+
"0M",
|
|
118
|
+
"--kafka-addr",
|
|
119
|
+
"0.0.0.0:9092",
|
|
120
|
+
"--advertise-kafka-addr",
|
|
121
|
+
`127.0.0.1:${kafkaPort}`,
|
|
122
|
+
]);
|
|
123
|
+
const redisId = await create("redis", [
|
|
124
|
+
"--publish",
|
|
125
|
+
"127.0.0.1::6379",
|
|
126
|
+
"--tmpfs",
|
|
127
|
+
"/data",
|
|
128
|
+
images.redis,
|
|
129
|
+
]);
|
|
130
|
+
const sqsId = await create("sqs", [
|
|
131
|
+
"--publish",
|
|
132
|
+
"127.0.0.1::9324",
|
|
133
|
+
images.sqs,
|
|
134
|
+
]);
|
|
135
|
+
const kafkaBroker = await binding(kafkaId, 9092);
|
|
136
|
+
const redisBroker = await binding(redisId, 6379);
|
|
137
|
+
const sqsEndpoint = `http://${await binding(sqsId, 9324)}`;
|
|
138
|
+
const { Kafka, logLevel } = require("kafkajs");
|
|
139
|
+
await ready("Kafka", async () => {
|
|
140
|
+
const admin = new Kafka({
|
|
141
|
+
brokers: [kafkaBroker],
|
|
142
|
+
logLevel: logLevel.NOTHING,
|
|
143
|
+
connectionTimeout: 1_000,
|
|
144
|
+
requestTimeout: 2_000,
|
|
145
|
+
retry: { retries: 0 },
|
|
146
|
+
}).admin();
|
|
147
|
+
try {
|
|
148
|
+
await admin.connect();
|
|
149
|
+
await admin.listTopics();
|
|
150
|
+
} finally {
|
|
151
|
+
await admin.disconnect();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
await ready("Redis", async () => {
|
|
155
|
+
if (
|
|
156
|
+
(await capture("docker", ["exec", redisId, "redis-cli", "ping"])) !==
|
|
157
|
+
"PONG"
|
|
158
|
+
)
|
|
159
|
+
throw new Error("Redis did not respond PONG");
|
|
160
|
+
});
|
|
161
|
+
const { SQSClient, ListQueuesCommand } = require("@aws-sdk/client-sqs");
|
|
162
|
+
const sqs = new SQSClient({
|
|
163
|
+
endpoint: sqsEndpoint,
|
|
164
|
+
region: "us-east-1",
|
|
165
|
+
credentials: { accessKeyId: "local", secretAccessKey: "local" },
|
|
166
|
+
maxAttempts: 1,
|
|
167
|
+
requestHandler: { connectionTimeout: 1_000, requestTimeout: 2_000 },
|
|
168
|
+
});
|
|
169
|
+
try {
|
|
170
|
+
await ready("ElasticMQ", () => sqs.send(new ListQueuesCommand({})));
|
|
171
|
+
} finally {
|
|
172
|
+
sqs.destroy();
|
|
173
|
+
}
|
|
174
|
+
checkInterrupted();
|
|
175
|
+
console.log(
|
|
176
|
+
"Disposable Kafka (Redpanda), Redis, and SQS-compatible ElasticMQ fixtures ready.",
|
|
177
|
+
);
|
|
178
|
+
return await run({
|
|
179
|
+
INBOX_EXAMPLE_KAFKA_BROKER: kafkaBroker,
|
|
180
|
+
INBOX_EXAMPLE_REDIS_URL: `redis://${redisBroker}`,
|
|
181
|
+
INBOX_EXAMPLE_SQS_ENDPOINT: sqsEndpoint,
|
|
182
|
+
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
for (const id of containers) {
|
|
185
|
+
try {
|
|
186
|
+
console.error(await capture("docker", ["logs", "--tail", "20", id]));
|
|
187
|
+
} catch {
|
|
188
|
+
/* preserve the original test failure */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
} finally {
|
|
193
|
+
process.removeListener("SIGINT", onInt);
|
|
194
|
+
process.removeListener("SIGTERM", onTerm);
|
|
195
|
+
const results = await Promise.allSettled(
|
|
196
|
+
containers.map((id) =>
|
|
197
|
+
capture("docker", ["rm", "--force", "--volumes", id]),
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
for (const result of results) {
|
|
201
|
+
if (result.status === "rejected") {
|
|
202
|
+
console.error(`Fixture cleanup failed: ${result.reason}`);
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function main() {
|
|
210
|
+
let child;
|
|
211
|
+
const onInt = () => child?.kill("SIGINT");
|
|
212
|
+
const onTerm = () => child?.kill("SIGTERM");
|
|
213
|
+
process.on("SIGINT", onInt);
|
|
214
|
+
process.on("SIGTERM", onTerm);
|
|
215
|
+
try {
|
|
216
|
+
await withBrokers(
|
|
217
|
+
(env) =>
|
|
218
|
+
new Promise((done, reject) => {
|
|
219
|
+
child = spawn(
|
|
220
|
+
process.execPath,
|
|
221
|
+
[
|
|
222
|
+
"scripts/with-test-db.mjs",
|
|
223
|
+
process.execPath,
|
|
224
|
+
"examples/scripts/smoke.cjs",
|
|
225
|
+
"--brokers",
|
|
226
|
+
],
|
|
227
|
+
{ cwd: root, env: { ...process.env, ...env }, stdio: "inherit" },
|
|
228
|
+
);
|
|
229
|
+
child.on("error", reject);
|
|
230
|
+
child.on("close", (code) => {
|
|
231
|
+
child = undefined;
|
|
232
|
+
code === 0
|
|
233
|
+
? done()
|
|
234
|
+
: reject(new Error(`Broker examples exited ${code}`));
|
|
235
|
+
});
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
} finally {
|
|
239
|
+
process.removeListener("SIGINT", onInt);
|
|
240
|
+
process.removeListener("SIGTERM", onTerm);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = { withBrokers };
|
|
245
|
+
if (require.main === module)
|
|
246
|
+
main().catch((error) => {
|
|
247
|
+
console.error(error);
|
|
248
|
+
process.exitCode = 1;
|
|
249
|
+
});
|