@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,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,92 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestarc/inbox",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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
|
+
"./sql/create-inbox-table.sql": "./src/sql/create-inbox-table.sql",
|
|
20
|
+
"./sql/migrate-v1-to-v2.sql": "./src/sql/migrate-v1-to-v2.sql"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src/sql",
|
|
25
|
+
"README.md",
|
|
26
|
+
"CHANGELOG.md",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"docs",
|
|
29
|
+
"examples",
|
|
30
|
+
"!examples/node_modules",
|
|
31
|
+
"!examples/dist",
|
|
32
|
+
"!examples/src/generated",
|
|
33
|
+
"!examples/package-lock.json"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.12.0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"pretypecheck": "prisma generate --config test/prisma/prisma.config.ts",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "vitest run test/unit",
|
|
43
|
+
"test:release": "node --test test/release/*.test.mjs",
|
|
44
|
+
"test:integration": "node scripts/with-test-db.mjs npm run test:integration:run",
|
|
45
|
+
"test:integration:run": "vitest run test/integration test/failure",
|
|
46
|
+
"test:package": "node scripts/with-test-db.mjs node scripts/test-package.mjs",
|
|
47
|
+
"test:examples": "node scripts/with-test-db.mjs node scripts/test-examples.mjs",
|
|
48
|
+
"prebenchmark": "npm run build",
|
|
49
|
+
"benchmark": "node scripts/with-test-db.mjs node scripts/benchmark.mjs",
|
|
50
|
+
"format": "prettier --write src test scripts .github/workflows README.md CHANGELOG.md docs/usage.md docs/releasing.md package.json tsconfig*.json vitest.config.mts",
|
|
51
|
+
"format:check": "prettier --check src test scripts .github/workflows README.md CHANGELOG.md docs/usage.md docs/releasing.md package.json tsconfig*.json vitest.config.mts",
|
|
52
|
+
"prepack": "npm run build"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@nestjs/common": "^11.2.8",
|
|
56
|
+
"@nestjs/core": "^11.2.8",
|
|
57
|
+
"@prisma/client": "^7.10.1",
|
|
58
|
+
"reflect-metadata": "^0.2.2",
|
|
59
|
+
"rxjs": "^7.8.2",
|
|
60
|
+
"@nestarc/tenancy": "^0.16.2"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@nestjs/common": "11.2.8",
|
|
64
|
+
"@nestjs/core": "11.2.8",
|
|
65
|
+
"@nestjs/testing": "11.2.8",
|
|
66
|
+
"@prisma/adapter-pg": "7.10.1",
|
|
67
|
+
"@prisma/client": "7.10.1",
|
|
68
|
+
"@types/node": "24.19.2",
|
|
69
|
+
"@types/pg": "8.23.1",
|
|
70
|
+
"pg": "8.23.1",
|
|
71
|
+
"prettier": "3.8.3",
|
|
72
|
+
"prisma": "7.10.1",
|
|
73
|
+
"reflect-metadata": "0.2.2",
|
|
74
|
+
"rxjs": "7.8.2",
|
|
75
|
+
"tsx": "4.23.15",
|
|
76
|
+
"typescript": "5.9.3",
|
|
77
|
+
"vitest": "5.0.5",
|
|
78
|
+
"@nestarc/tenancy": "0.16.2"
|
|
79
|
+
},
|
|
80
|
+
"publishConfig": {
|
|
81
|
+
"access": "public",
|
|
82
|
+
"tag": "latest"
|
|
83
|
+
},
|
|
84
|
+
"peerDependenciesMeta": {
|
|
85
|
+
"@nestarc/tenancy": {
|
|
86
|
+
"optional": true
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"workspaces": [
|
|
90
|
+
"src/internal/reliability-core"
|
|
91
|
+
]
|
|
92
|
+
}
|
|
@@ -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;
|