@nestarc/inbox 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +309 -2
  4. package/dist/contracts/errors.d.ts +34 -0
  5. package/dist/contracts/errors.js +73 -0
  6. package/dist/contracts/errors.js.map +1 -0
  7. package/dist/contracts/types.d.ts +174 -0
  8. package/dist/contracts/types.js +3 -0
  9. package/dist/contracts/types.js.map +1 -0
  10. package/dist/inbox-admin.service.d.ts +44 -0
  11. package/dist/inbox-admin.service.js +102 -0
  12. package/dist/inbox-admin.service.js.map +1 -0
  13. package/dist/inbox.module.d.ts +13 -0
  14. package/dist/inbox.module.js +59 -0
  15. package/dist/inbox.module.js.map +1 -0
  16. package/dist/inbox.service.d.ts +22 -0
  17. package/dist/inbox.service.js +74 -0
  18. package/dist/inbox.service.js.map +1 -0
  19. package/dist/index.d.ts +9 -0
  20. package/dist/index.js +28 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/integrations/tenancy.d.ts +25 -0
  23. package/dist/integrations/tenancy.js +56 -0
  24. package/dist/integrations/tenancy.js.map +1 -0
  25. package/dist/internal/handler-context.d.ts +3 -0
  26. package/dist/internal/handler-context.js +39 -0
  27. package/dist/internal/handler-context.js.map +1 -0
  28. package/dist/internal/options.d.ts +5 -0
  29. package/dist/internal/options.js +86 -0
  30. package/dist/internal/options.js.map +1 -0
  31. package/dist/internal/reliability-core/abort.d.ts +2 -0
  32. package/dist/internal/reliability-core/abort.js +21 -0
  33. package/dist/internal/reliability-core/abort.js.map +1 -0
  34. package/dist/internal/reliability-core/index.d.ts +3 -0
  35. package/dist/internal/reliability-core/index.js +14 -0
  36. package/dist/internal/reliability-core/index.js.map +1 -0
  37. package/dist/internal/reliability-core/json.d.ts +13 -0
  38. package/dist/internal/reliability-core/json.js +125 -0
  39. package/dist/internal/reliability-core/json.js.map +1 -0
  40. package/dist/internal/reliability-core/retry.d.ts +8 -0
  41. package/dist/internal/reliability-core/retry.js +26 -0
  42. package/dist/internal/reliability-core/retry.js.map +1 -0
  43. package/dist/internal/validation.d.ts +6 -0
  44. package/dist/internal/validation.js +123 -0
  45. package/dist/internal/validation.js.map +1 -0
  46. package/dist/storage/prisma/prisma-inbox.storage.d.ts +30 -0
  47. package/dist/storage/prisma/prisma-inbox.storage.js +369 -0
  48. package/dist/storage/prisma/prisma-inbox.storage.js.map +1 -0
  49. package/dist/testing/index.d.ts +40 -0
  50. package/dist/testing/index.js +120 -0
  51. package/dist/testing/index.js.map +1 -0
  52. package/dist/worker/inbox-worker.d.ts +31 -0
  53. package/dist/worker/inbox-worker.js +343 -0
  54. package/dist/worker/inbox-worker.js.map +1 -0
  55. package/docs/adr/0001-durable-inbox.md +40 -0
  56. package/docs/benchmarks/2026-10-27-local.md +31 -0
  57. package/docs/benchmarks/2026-10-28-preview1-local.md +87 -0
  58. package/docs/benchmarks/2026-10-28-preview1-run1.json +120 -0
  59. package/docs/benchmarks/2026-10-28-preview1-run2.json +120 -0
  60. package/docs/benchmarks/2026-10-28-preview1-run3.json +120 -0
  61. package/docs/benchmarks/2026-10-28-sustained-local.md +104 -0
  62. package/docs/benchmarks/2026-10-28-sustained-run1.json +2553 -0
  63. package/docs/benchmarks/2026-10-28-sustained-run2.json +2553 -0
  64. package/docs/broker-recovery-results.json +52 -0
  65. package/docs/broker-recovery.md +30 -0
  66. package/docs/db-operations.md +41 -0
  67. package/docs/implementation-roadmap.md +129 -0
  68. package/docs/operations/2026-10-28-database.json +34 -0
  69. package/docs/package-plan.md +250 -0
  70. package/docs/releasing.md +91 -0
  71. package/docs/testing.md +86 -0
  72. package/docs/usage.md +266 -0
  73. package/docs/validation.md +174 -0
  74. package/examples/README.md +124 -0
  75. package/examples/compose.yaml +26 -0
  76. package/examples/package.json +42 -0
  77. package/examples/prisma/schema.prisma +29 -0
  78. package/examples/prisma.config.ts +10 -0
  79. package/examples/scripts/prepare-db.cjs +45 -0
  80. package/examples/scripts/smoke.cjs +37 -0
  81. package/examples/scripts/with-brokers.cjs +249 -0
  82. package/examples/scripts/with-rabbitmq.cjs +131 -0
  83. package/examples/sql/application.sql +14 -0
  84. package/examples/sql/tenancy.sql +26 -0
  85. package/examples/src/broker-common.ts +79 -0
  86. package/examples/src/bullmq-ingress/main.ts +165 -0
  87. package/examples/src/common.ts +112 -0
  88. package/examples/src/idempotency-ingress/main.ts +211 -0
  89. package/examples/src/kafka-ingress/main.ts +209 -0
  90. package/examples/src/outbox-to-inbox/main.ts +198 -0
  91. package/examples/src/rabbitmq-ingress/main.ts +203 -0
  92. package/examples/src/sqs-ingress/main.ts +273 -0
  93. package/examples/src/tenancy/main.ts +210 -0
  94. package/examples/src/webhook-ingress/main.ts +172 -0
  95. package/examples/tsconfig.json +16 -0
  96. package/package.json +99 -4
  97. package/src/sql/create-inbox-table.sql +100 -0
  98. package/src/sql/migrate-v1-to-v2.sql +53 -0
@@ -0,0 +1,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
+ });
@@ -0,0 +1,172 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
3
+ import { createServer, type IncomingMessage } from "node:http";
4
+ import {
5
+ InboxConflictError,
6
+ InboxService,
7
+ InboxValidationError,
8
+ } from "@nestarc/inbox";
9
+ import { startInbox, tenantId, waitUntil } from "../common";
10
+
11
+ const maxBytes = 16 * 1024;
12
+
13
+ async function body(request: IncomingMessage): Promise<Buffer> {
14
+ const chunks: Buffer[] = [];
15
+ let size = 0;
16
+ for await (const chunk of request) {
17
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
18
+ size += buffer.length;
19
+ if (size > maxBytes)
20
+ throw new InboxValidationError("Webhook body is too large.");
21
+ chunks.push(buffer);
22
+ }
23
+ return Buffer.concat(chunks);
24
+ }
25
+
26
+ async function main() {
27
+ const secret = process.env.INBOX_EXAMPLE_WEBHOOK_SECRET;
28
+ if (!secret || Buffer.byteLength(secret) < 32) {
29
+ throw new Error(
30
+ "Set INBOX_EXAMPLE_WEBHOOK_SECRET to a random secret of at least 32 bytes.",
31
+ );
32
+ }
33
+ const fixture = await startInbox("example.webhook.v1", "order.created");
34
+ const inbox = fixture.app.get(InboxService);
35
+ const server = createServer((request, response) => {
36
+ void (async () => {
37
+ if (
38
+ request.method !== "POST" ||
39
+ request.url !== "/webhooks/example-account"
40
+ ) {
41
+ response.writeHead(404).end();
42
+ return;
43
+ }
44
+ const bytes = await body(request);
45
+ const timestamp = request.headers["x-example-timestamp"];
46
+ const signature = request.headers["x-example-signature"];
47
+ if (
48
+ typeof timestamp !== "string" ||
49
+ !/^\d{10}$/.test(timestamp) ||
50
+ Math.abs(Date.now() / 1000 - Number(timestamp)) > 300 ||
51
+ typeof signature !== "string" ||
52
+ !/^[a-f0-9]{64}$/.test(signature)
53
+ ) {
54
+ response.writeHead(401).end();
55
+ return;
56
+ }
57
+ const expected = createHmac("sha256", secret)
58
+ .update(timestamp)
59
+ .update(".")
60
+ .update(bytes)
61
+ .digest();
62
+ if (!timingSafeEqual(expected, Buffer.from(signature, "hex"))) {
63
+ response.writeHead(401).end();
64
+ return;
65
+ }
66
+ // Signature verification precedes JSON interpretation and tenant assignment.
67
+ let parsed: unknown;
68
+ try {
69
+ parsed = JSON.parse(bytes.toString("utf8"));
70
+ } catch {
71
+ response.writeHead(400).end();
72
+ return;
73
+ }
74
+ if (
75
+ !parsed ||
76
+ typeof parsed !== "object" ||
77
+ Array.isArray(parsed) ||
78
+ typeof (parsed as { id?: unknown }).id !== "string" ||
79
+ typeof (parsed as { orderId?: unknown }).orderId !== "string"
80
+ ) {
81
+ response.writeHead(400).end();
82
+ return;
83
+ }
84
+ const event = parsed as { id: string; orderId: string };
85
+ const result = await inbox.accept({
86
+ scope: { kind: "tenant", id: tenantId }, // server-side account → tenant mapping
87
+ source: "example-webhook-account",
88
+ consumer: "example.webhook.v1",
89
+ messageId: event.id,
90
+ eventType: "order.created",
91
+ schemaVersion: 1,
92
+ payload: { orderId: event.orderId },
93
+ });
94
+ response.writeHead(result.kind === "accepted" ? 202 : 200, {
95
+ "content-type": "application/json",
96
+ });
97
+ response.end(JSON.stringify({ kind: result.kind }));
98
+ })().catch((error: unknown) => {
99
+ const status =
100
+ error instanceof InboxConflictError
101
+ ? 409
102
+ : error instanceof InboxValidationError
103
+ ? 400
104
+ : 503;
105
+ if (!response.headersSent) response.writeHead(status);
106
+ response.end();
107
+ });
108
+ });
109
+ try {
110
+ await new Promise<void>((resolve, reject) => {
111
+ server.once("error", reject);
112
+ server.listen(0, "127.0.0.1", () => {
113
+ server.off("error", reject);
114
+ resolve();
115
+ });
116
+ });
117
+ const address = server.address();
118
+ assert.ok(address && typeof address !== "string");
119
+ const url = `http://127.0.0.1:${address.port}/webhooks/example-account`;
120
+ const orderId = randomUUID();
121
+ const content = JSON.stringify({ id: randomUUID(), orderId });
122
+ const timestamp = String(Math.floor(Date.now() / 1000));
123
+ const signature = createHmac("sha256", secret)
124
+ .update(`${timestamp}.${content}`)
125
+ .digest("hex");
126
+ const headers = {
127
+ "content-type": "application/json",
128
+ "x-example-timestamp": timestamp,
129
+ "x-example-signature": signature,
130
+ };
131
+ const rejected = await fetch(url, {
132
+ method: "POST",
133
+ body: content,
134
+ headers: { ...headers, "x-example-signature": "0".repeat(64) },
135
+ });
136
+ assert.equal(rejected.status, 401);
137
+ await rejected.arrayBuffer();
138
+ const first = await fetch(url, { method: "POST", body: content, headers });
139
+ assert.equal(first.status, 202);
140
+ assert.deepEqual(await first.json(), { kind: "accepted" });
141
+ const duplicate = await fetch(url, {
142
+ method: "POST",
143
+ body: content,
144
+ headers,
145
+ });
146
+ assert.equal(duplicate.status, 200);
147
+ assert.deepEqual(await duplicate.json(), { kind: "duplicate" });
148
+ await waitUntil(
149
+ async () =>
150
+ (await fixture.prisma.exampleProjection.count({
151
+ where: { tenantId, orderId },
152
+ })) === 1,
153
+ "signed webhook projection",
154
+ );
155
+ console.log(
156
+ JSON.stringify({
157
+ invalidSignature: 401,
158
+ accepted: 202,
159
+ duplicate: 200,
160
+ projections: 1,
161
+ }),
162
+ );
163
+ } finally {
164
+ await new Promise<void>((resolve) => server.close(() => resolve()));
165
+ await fixture.close();
166
+ }
167
+ }
168
+
169
+ void main().catch((error: unknown) => {
170
+ console.error(error);
171
+ process.exitCode = 1;
172
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "rootDir": "src",
7
+ "outDir": "dist",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "experimentalDecorators": true,
11
+ "emitDecoratorMetadata": true,
12
+ "skipLibCheck": true,
13
+ "sourceMap": true
14
+ },
15
+ "include": ["src/**/*.ts"]
16
+ }
package/package.json CHANGED
@@ -1,6 +1,101 @@
1
1
  {
2
2
  "name": "@nestarc/inbox",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Transactional inbox for NestJS, Prisma and PostgreSQL with durable deduplication, fenced processing and retries.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/nestarc/inbox.git"
9
+ },
10
+ "homepage": "https://github.com/nestarc/inbox#readme",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "require": "./dist/index.js",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./testing": {
20
+ "types": "./dist/testing/index.d.ts",
21
+ "require": "./dist/testing/index.js",
22
+ "default": "./dist/testing/index.js"
23
+ },
24
+ "./sql/create-inbox-table.sql": "./src/sql/create-inbox-table.sql",
25
+ "./sql/migrate-v1-to-v2.sql": "./src/sql/migrate-v1-to-v2.sql"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "src/sql",
30
+ "README.md",
31
+ "CHANGELOG.md",
32
+ "LICENSE",
33
+ "docs",
34
+ "examples",
35
+ "!examples/node_modules",
36
+ "!examples/dist",
37
+ "!examples/src/generated",
38
+ "!examples/package-lock.json"
39
+ ],
40
+ "engines": {
41
+ "node": ">=22.12.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.build.json",
45
+ "pretypecheck": "prisma generate --config test/prisma/prisma.config.ts",
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run test/unit",
48
+ "test:release": "node --test test/release/*.test.mjs",
49
+ "test:integration": "node scripts/with-test-db.mjs npm run test:integration:run",
50
+ "test:integration:run": "vitest run test/integration test/failure",
51
+ "test:operations": "node scripts/with-test-db.mjs node node_modules/vitest/vitest.mjs run test/operations",
52
+ "test:operations:database": "node scripts/test-db-operations.mjs",
53
+ "test:broker-recovery": "node scripts/with-test-db.mjs node scripts/test-broker-recovery.mjs",
54
+ "test:package": "node scripts/with-test-db.mjs node scripts/test-package.mjs",
55
+ "test:examples": "node scripts/with-test-db.mjs node scripts/test-examples.mjs",
56
+ "prebenchmark": "npm run build",
57
+ "benchmark": "node scripts/with-test-db.mjs node scripts/benchmark.mjs",
58
+ "benchmark:sustained": "node scripts/with-benchmark-db.mjs",
59
+ "format": "prettier --write src test scripts .github/workflows README.md CHANGELOG.md docs/usage.md docs/releasing.md docs/testing.md package.json tsconfig*.json vitest.config.mts",
60
+ "format:check": "prettier --check src test scripts .github/workflows README.md CHANGELOG.md docs/usage.md docs/releasing.md docs/testing.md package.json tsconfig*.json vitest.config.mts",
61
+ "prepack": "npm run build"
62
+ },
63
+ "peerDependencies": {
64
+ "@nestjs/common": "^11.2.8",
65
+ "@nestjs/core": "^11.2.8",
66
+ "@prisma/client": "^7.10.1",
67
+ "reflect-metadata": "^0.2.2",
68
+ "rxjs": "^7.8.2",
69
+ "@nestarc/tenancy": "^0.16.2"
70
+ },
71
+ "devDependencies": {
72
+ "@nestjs/common": "11.2.8",
73
+ "@nestjs/core": "11.2.8",
74
+ "@nestjs/testing": "11.2.8",
75
+ "@prisma/adapter-pg": "7.10.1",
76
+ "@prisma/client": "7.10.1",
77
+ "@types/node": "24.19.2",
78
+ "@types/pg": "8.23.1",
79
+ "pg": "8.23.1",
80
+ "prettier": "3.8.3",
81
+ "prisma": "7.10.1",
82
+ "reflect-metadata": "0.2.2",
83
+ "rxjs": "7.8.2",
84
+ "tsx": "4.23.15",
85
+ "typescript": "5.9.3",
86
+ "vitest": "5.0.5",
87
+ "@nestarc/tenancy": "0.16.2"
88
+ },
89
+ "publishConfig": {
90
+ "access": "public",
91
+ "tag": "latest"
92
+ },
93
+ "peerDependenciesMeta": {
94
+ "@nestarc/tenancy": {
95
+ "optional": true
96
+ }
97
+ },
98
+ "workspaces": [
99
+ "src/internal/reliability-core"
100
+ ]
101
+ }
@@ -0,0 +1,100 @@
1
+ -- @nestarc/inbox schema version 2. Run explicitly with your application's migrations.
2
+ -- Existing version 1 installations must apply migrate-v1-to-v2.sql instead.
3
+ -- Runtime never creates or upgrades tables. Do not let Prisma db push remove these tables.
4
+ BEGIN;
5
+
6
+ CREATE TABLE IF NOT EXISTS public.nestarc_inbox_schema (
7
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
8
+ version integer NOT NULL
9
+ );
10
+
11
+ CREATE TABLE IF NOT EXISTS public.nestarc_inbox (
12
+ id uuid PRIMARY KEY,
13
+ scope_kind text NOT NULL CHECK (scope_kind IN ('tenant', 'global')),
14
+ scope_id text NOT NULL,
15
+ source text NOT NULL CHECK (octet_length(source) BETWEEN 1 AND 128),
16
+ consumer text NOT NULL CHECK (octet_length(consumer) BETWEEN 1 AND 128),
17
+ message_id text NOT NULL CHECK (octet_length(message_id) BETWEEN 1 AND 256),
18
+ event_type text NOT NULL CHECK (octet_length(event_type) BETWEEN 1 AND 128),
19
+ schema_version integer NOT NULL CHECK (schema_version > 0),
20
+ payload jsonb,
21
+ payload_purged boolean NOT NULL DEFAULT false,
22
+ fingerprint text NOT NULL CHECK (fingerprint ~ '^[0-9a-f]{64}$'),
23
+ fingerprint_version integer NOT NULL CHECK (fingerprint_version = 1),
24
+ status text NOT NULL DEFAULT 'PENDING'
25
+ CHECK (status IN ('PENDING', 'PROCESSING', 'RETRYABLE', 'COMPLETED', 'DEAD')),
26
+ claim_token bigint NOT NULL DEFAULT 0 CHECK (claim_token >= 0),
27
+ row_version bigint NOT NULL DEFAULT 1 CHECK (row_version > 0),
28
+ attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
29
+ cycle_attempt_count integer NOT NULL DEFAULT 0 CHECK (cycle_attempt_count >= 0),
30
+ requeue_count integer NOT NULL DEFAULT 0 CHECK (requeue_count >= 0),
31
+ received_at timestamptz NOT NULL DEFAULT clock_timestamp(),
32
+ completed_at timestamptz,
33
+ next_attempt_at timestamptz NOT NULL DEFAULT clock_timestamp(),
34
+ lease_expires_at timestamptz,
35
+ worker_id text,
36
+ last_error_code text,
37
+ last_error_message text,
38
+ last_requeue_reason text,
39
+ last_requeue_actor text,
40
+ last_requeue_at timestamptz,
41
+ correlation_id text,
42
+ causation_id text,
43
+ headers jsonb NOT NULL DEFAULT '{}'::jsonb,
44
+ replay_of uuid,
45
+ replay_request_id uuid,
46
+ replay_source_version bigint,
47
+ replay_actor text,
48
+ replay_reason text,
49
+ replayed_at timestamptz,
50
+ CONSTRAINT nestarc_inbox_identity_key
51
+ UNIQUE (scope_kind, scope_id, source, consumer, message_id),
52
+ CONSTRAINT nestarc_inbox_replay_request_key
53
+ UNIQUE (scope_kind, scope_id, replay_request_id),
54
+ CONSTRAINT nestarc_inbox_replay_original_fk
55
+ FOREIGN KEY (replay_of) REFERENCES public.nestarc_inbox(id),
56
+ CONSTRAINT nestarc_inbox_replay_check CHECK (
57
+ (replay_of IS NULL AND replay_request_id IS NULL AND replay_source_version IS NULL
58
+ AND replay_actor IS NULL AND replay_reason IS NULL AND replayed_at IS NULL) OR
59
+ (replay_of IS NOT NULL AND replay_of <> id AND replay_request_id IS NOT NULL
60
+ AND replay_source_version IS NOT NULL AND replay_source_version > 0
61
+ AND replay_actor IS NOT NULL AND octet_length(replay_actor) BETWEEN 1 AND 256
62
+ AND replay_reason IS NOT NULL AND octet_length(replay_reason) BETWEEN 1 AND 1024
63
+ AND replayed_at IS NOT NULL)
64
+ ),
65
+ CONSTRAINT nestarc_inbox_scope_check CHECK (
66
+ (scope_kind = 'global' AND scope_id = '') OR
67
+ (scope_kind = 'tenant' AND octet_length(scope_id) BETWEEN 1 AND 128)
68
+ ),
69
+ CONSTRAINT nestarc_inbox_payload_check CHECK (
70
+ (payload_purged AND payload IS NULL AND status = 'COMPLETED') OR
71
+ (NOT payload_purged AND payload IS NOT NULL)
72
+ ),
73
+ CONSTRAINT nestarc_inbox_lease_check CHECK (
74
+ (status = 'PROCESSING' AND lease_expires_at IS NOT NULL AND worker_id IS NOT NULL) OR
75
+ (status <> 'PROCESSING' AND lease_expires_at IS NULL AND worker_id IS NULL)
76
+ ),
77
+ CONSTRAINT nestarc_inbox_completion_check CHECK (
78
+ (status = 'COMPLETED' AND completed_at IS NOT NULL) OR
79
+ (status <> 'COMPLETED' AND completed_at IS NULL)
80
+ )
81
+ );
82
+
83
+ CREATE INDEX IF NOT EXISTS nestarc_inbox_due_idx
84
+ ON public.nestarc_inbox (consumer, next_attempt_at, id)
85
+ WHERE status IN ('PENDING', 'RETRYABLE');
86
+ CREATE INDEX IF NOT EXISTS nestarc_inbox_expired_idx
87
+ ON public.nestarc_inbox (consumer, lease_expires_at, id)
88
+ WHERE status = 'PROCESSING';
89
+ CREATE INDEX IF NOT EXISTS nestarc_inbox_scope_list_idx
90
+ ON public.nestarc_inbox (scope_kind, scope_id, id);
91
+ CREATE INDEX IF NOT EXISTS nestarc_inbox_completed_payload_idx
92
+ ON public.nestarc_inbox (completed_at, id)
93
+ WHERE status = 'COMPLETED' AND NOT payload_purged;
94
+ CREATE INDEX IF NOT EXISTS nestarc_inbox_replay_original_idx
95
+ ON public.nestarc_inbox (replay_of) WHERE replay_of IS NOT NULL;
96
+
97
+ INSERT INTO public.nestarc_inbox_schema (singleton, version)
98
+ VALUES (true, 2) ON CONFLICT (singleton) DO NOTHING;
99
+
100
+ COMMIT;
@@ -0,0 +1,53 @@
1
+ -- Add completed-message replay lineage to an existing @nestarc/inbox v1 installation.
2
+ -- Run explicitly during deployment before starting the v2 application. Transactional
3
+ -- DDL preserves all receipts, fingerprints, processing state, and business writes.
4
+ BEGIN;
5
+
6
+ LOCK TABLE public.nestarc_inbox IN ACCESS EXCLUSIVE MODE;
7
+ DO $$
8
+ BEGIN
9
+ IF NOT EXISTS (
10
+ SELECT 1 FROM public.nestarc_inbox_schema WHERE singleton = true AND version IN (1, 2)
11
+ ) THEN
12
+ RAISE EXCEPTION 'Expected inbox schema version 1 or 2';
13
+ END IF;
14
+ END $$;
15
+
16
+ ALTER TABLE public.nestarc_inbox
17
+ ADD COLUMN IF NOT EXISTS replay_of uuid,
18
+ ADD COLUMN IF NOT EXISTS replay_request_id uuid,
19
+ ADD COLUMN IF NOT EXISTS replay_source_version bigint,
20
+ ADD COLUMN IF NOT EXISTS replay_actor text,
21
+ ADD COLUMN IF NOT EXISTS replay_reason text,
22
+ ADD COLUMN IF NOT EXISTS replayed_at timestamptz;
23
+
24
+ DO $$
25
+ BEGIN
26
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
27
+ WHERE conrelid='public.nestarc_inbox'::regclass AND conname='nestarc_inbox_replay_request_key') THEN
28
+ ALTER TABLE public.nestarc_inbox ADD CONSTRAINT nestarc_inbox_replay_request_key
29
+ UNIQUE (scope_kind, scope_id, replay_request_id);
30
+ END IF;
31
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
32
+ WHERE conrelid='public.nestarc_inbox'::regclass AND conname='nestarc_inbox_replay_original_fk') THEN
33
+ ALTER TABLE public.nestarc_inbox ADD CONSTRAINT nestarc_inbox_replay_original_fk
34
+ FOREIGN KEY (replay_of) REFERENCES public.nestarc_inbox(id);
35
+ END IF;
36
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
37
+ WHERE conrelid='public.nestarc_inbox'::regclass AND conname='nestarc_inbox_replay_check') THEN
38
+ ALTER TABLE public.nestarc_inbox ADD CONSTRAINT nestarc_inbox_replay_check CHECK (
39
+ (replay_of IS NULL AND replay_request_id IS NULL AND replay_source_version IS NULL
40
+ AND replay_actor IS NULL AND replay_reason IS NULL AND replayed_at IS NULL) OR
41
+ (replay_of IS NOT NULL AND replay_of <> id AND replay_request_id IS NOT NULL
42
+ AND replay_source_version IS NOT NULL AND replay_source_version > 0
43
+ AND replay_actor IS NOT NULL AND octet_length(replay_actor) BETWEEN 1 AND 256
44
+ AND replay_reason IS NOT NULL AND octet_length(replay_reason) BETWEEN 1 AND 1024
45
+ AND replayed_at IS NOT NULL)
46
+ );
47
+ END IF;
48
+ END $$;
49
+
50
+ CREATE INDEX IF NOT EXISTS nestarc_inbox_replay_original_idx
51
+ ON public.nestarc_inbox (replay_of) WHERE replay_of IS NOT NULL;
52
+ UPDATE public.nestarc_inbox_schema SET version=2 WHERE singleton=true;
53
+ COMMIT;