@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.
- package/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +309 -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/testing/index.d.ts +40 -0
- package/dist/testing/index.js +120 -0
- package/dist/testing/index.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/benchmarks/2026-10-28-sustained-local.md +104 -0
- package/docs/benchmarks/2026-10-28-sustained-run1.json +2553 -0
- package/docs/benchmarks/2026-10-28-sustained-run2.json +2553 -0
- package/docs/broker-recovery-results.json +52 -0
- package/docs/broker-recovery.md +30 -0
- package/docs/db-operations.md +41 -0
- package/docs/implementation-roadmap.md +129 -0
- package/docs/operations/2026-10-28-database.json +34 -0
- package/docs/package-plan.md +250 -0
- package/docs/releasing.md +91 -0
- package/docs/testing.md +86 -0
- package/docs/usage.md +266 -0
- package/docs/validation.md +174 -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 +99 -4
- package/src/sql/create-inbox-table.sql +100 -0
- package/src/sql/migrate-v1-to-v2.sql +53 -0
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|