@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
User-facing changes and upgrade actions for `@nestarc/inbox`.
|
|
4
|
+
|
|
5
|
+
## 0.2.0 — 2026-10-28
|
|
6
|
+
|
|
7
|
+
Adds application integration-test helpers and reproducible operational recovery checks. Runtime processing contracts and SQL schema v2 are unchanged; upgrading from 0.1.0 requires no database migration.
|
|
8
|
+
|
|
9
|
+
### Testing helpers
|
|
10
|
+
|
|
11
|
+
- Added `@nestarc/inbox/testing` with `drainInbox()`, `waitForInboxRecord()`, and typed `InboxTestError` diagnostics. Helpers wait through persisted retry schedules and bound database reads and worker calls with a timeout, cancellation signal, and pass limit. They use the real service and scoped admin API; they do not create databases or cancel transactions already in flight. [Testing guide](docs/testing.md)
|
|
12
|
+
- Verified the subpath with unit tests, PostgreSQL integration tests, and a separately installed tarball consumer using strict TypeScript, CommonJS, and dynamic ESM.
|
|
13
|
+
|
|
14
|
+
### Operational validation
|
|
15
|
+
|
|
16
|
+
- Added actual TCP disconnect/blackhole, connection-pool saturation, and child-process SIGTERM recovery tests.
|
|
17
|
+
- Added PostgreSQL physical standby promotion after confirmed WAL replay and real SQLSTATE `53100` in a bounded disposable tablespace, including rollback and recovery assertions. [Database operations](docs/db-operations.md)
|
|
18
|
+
- Added RabbitMQ process restarts, unacknowledged redelivery, persisted retry/TTL delivery, and dead-letter routing tests. [Broker recovery](docs/broker-recovery.md)
|
|
19
|
+
- Added a disk-backed sustained benchmark with simultaneous ingress and processing, pool pressure, hot/cold tenants, retry recovery, and receipt/payload storage measurements. It verifies business effects separately from receipt completion. These bounded local measurements do not establish production capacity or an SLA.
|
|
20
|
+
- Added a Node 24/PostgreSQL 16 operational recovery gate to CI and release validation. Real-service adoption remains outside this release's validation scope.
|
|
21
|
+
|
|
22
|
+
### Release automation
|
|
23
|
+
|
|
24
|
+
- Wait up to 20 minutes for npm's post-publication scanning before verifying registry integrity. Pending 404 responses are retried every 30 seconds; integrity mismatches and other lookup failures still stop immediately. This prevents successful uploads from being reported as failed after only 16 seconds. No runtime API or schema changes.
|
|
25
|
+
|
|
26
|
+
## 0.1.0 — 2026-10-28
|
|
27
|
+
|
|
28
|
+
First public release version. It promotes the runtime implementation and schema verified as `0.1.0-preview.1`, with version metadata and documentation updates. Preview.0 and Preview.1 were local development baselines, not published releases. APIs and schema may change before 1.0.
|
|
29
|
+
|
|
30
|
+
### Upgrade action: schema version 2
|
|
31
|
+
|
|
32
|
+
Existing schema v1 installations from local development builds must migrate before running 0.1.0:
|
|
33
|
+
|
|
34
|
+
1. Stop processes using the schema v1 implementation.
|
|
35
|
+
2. Apply [`@nestarc/inbox/sql/migrate-v1-to-v2.sql`](src/sql/migrate-v1-to-v2.sql) through the application's migration process. It takes an exclusive table lock and preserves existing receipts, fingerprints, processing state, attempts, and claim tokens.
|
|
36
|
+
3. Deploy 0.1.0 and keep schema v1 workers stopped. Runtime schema checks reject incompatible versions; Inbox does not run migrations automatically.
|
|
37
|
+
|
|
38
|
+
Fresh installations use [`@nestarc/inbox/sql/create-inbox-table.sql`](src/sql/create-inbox-table.sql). See the [schema setup and upgrade guide](docs/usage.md#initialize-the-schema-and-prisma-client).
|
|
39
|
+
|
|
40
|
+
### Core processing
|
|
41
|
+
|
|
42
|
+
- Durable `accept()` with stable message identity, canonical payload fingerprints, duplicate receipts, and explicit identity/content conflicts. Acknowledge transport delivery after acceptance commits.
|
|
43
|
+
- PostgreSQL workers with atomic claims, renewable leases, fenced completion, persisted retries, and DEAD records. Business writes through `context.tx`, optional outbox writes, and inbox completion commit together.
|
|
44
|
+
- Tenant/global administration for status, backlog statistics, version-checked DEAD requeue, and completed-payload cleanup that preserves deduplication history.
|
|
45
|
+
|
|
46
|
+
### Replay and integrations
|
|
47
|
+
|
|
48
|
+
- **Completed-message replay:** The scoped admin's `replay()` method, accessed through `forTenant(id)` or `forGlobal()`, creates a separate receipt while preserving the original completion and deduplication identity. A persisted operator request UUID makes retries idempotent; records and handler context include replay lineage. New replay requests require a retained payload and the expected source version. Replay deliberately permits another business execution. [Replay contract](docs/usage.md#explicit-replay-of-a-completed-message)
|
|
49
|
+
- **Optional tenancy integration:** `createInboxTenancyContext()` carries `@nestarc/tenancy` context into the existing handler transaction and sets the canonical PostgreSQL tenant setting. Global messages are rejected by default; explicit shared-table processing does not bypass RLS. Applications provision their own policies and restricted roles. [Integration guide](docs/usage.md#tenant-context-in-the-handler-transaction)
|
|
50
|
+
- **Handler context wrappers:** `handlerContext(context, next)` supports transaction-bound setup. Completion is blocked when a wrapper skips or repeats the handler, swallows its failure, or tries to start it after cancellation.
|
|
51
|
+
- **More runnable integrations:** Kafka, SQS-compatible, BullMQ, tenancy, and PostgreSQL-backed HTTP idempotency examples join the outbox, RabbitMQ, and signed webhook examples. They demonstrate acceptance/ACK boundaries, duplicate delivery, retries, and invalid-message handling. [Examples and fixture coverage](examples/README.md)
|
|
52
|
+
|
|
53
|
+
### Reliability and packaging
|
|
54
|
+
|
|
55
|
+
- Extracted canonical JSON/hash, bounded retry, and cancellation helpers into a private source workspace while preserving v1 fingerprints. Other Nestarc repositories have not been migrated to this workspace.
|
|
56
|
+
- Added database coverage for replay races, schema v1→v2 migration, restricted-role RLS, and tenant-setting cleanup after commit and rollback. Existing crash-recovery and stale-worker tests remain part of validation.
|
|
57
|
+
- Packed-consumer checks cover CommonJS/ESM imports, public types, SQL exports, replay, and installation without the optional tenancy peer. Generated clients, dependency directories, and environment files are excluded from the artifact.
|
|
58
|
+
|
|
59
|
+
### Release workflow
|
|
60
|
+
|
|
61
|
+
- Added shared CI/release validation for Node 22/24 and PostgreSQL 16/18. The workflow builds one tarball, checks that same artifact in independent consumers and runnable examples, and verifies its integrity before publication.
|
|
62
|
+
- GitHub Release publication is configured to use npm Trusted Publishing with provenance. Prereleases target `next` and release versions such as 0.1.0 target `latest`; manual workflow runs validate and perform a publication dry run without publishing. [Release setup and publishing procedure](docs/releasing.md)
|
|
63
|
+
|
|
64
|
+
### Validation status
|
|
65
|
+
|
|
66
|
+
Local unit, database, process-crash, packed-consumer, and eight-example checks passed for the Preview.1 baseline. GitHub-hosted [CI run 40684612217](https://github.com/nestarc/inbox/actions/runs/40684612217), at commit `664b4fc`, also passed all four Node 22/24 × PostgreSQL 16/18 combinations and the examples. These results cover the runtime promoted to 0.1.0; they are not evidence of a later tag's CI run or successful npm OIDC authentication. Exact versions and remaining operational tests are recorded in [validation results](docs/validation.md).
|
|
67
|
+
|
|
68
|
+
Kafka examples were exercised with Redpanda and SQS examples with ElasticMQ; production AWS SQS behavior still requires deployment-specific validation.
|
|
69
|
+
|
|
70
|
+
Included [three local Preview.1 benchmark reports](docs/benchmarks/2026-10-28-preview1-local.md) with raw JSON. All nine scenarios verified the expected committed effects and zero observed acceptance or worker errors. Their recorded package version remains `0.1.0-preview.1`; promotion to 0.1.0 changes no runtime code or schema. The measurements separate receipt acceptance from worker draining and record environment and sample variation; they are not a controlled speedup comparison or a production capacity estimate.
|
|
71
|
+
|
|
72
|
+
Handler calls may repeat; the atomicity guarantee applies to writes in the supplied database transaction while the receipt is retained.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nestarc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,310 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nestarc/inbox
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Turn repeated deliveries into one committed database outcome per retained message identity.**
|
|
4
|
+
|
|
5
|
+
A transactional inbox for **NestJS, Prisma, and PostgreSQL**. Store an incoming event durably, acknowledge its delivery, then commit your business writes and inbox completion in one database transaction. Retries and crash recovery use the same durable record.
|
|
6
|
+
|
|
7
|
+
**Version `0.2.0` · SQL schema v2.** APIs and schema may change before 1.0; review the [changelog](CHANGELOG.md) and migration steps when upgrading.
|
|
8
|
+
|
|
9
|
+
[Install](#1-install-the-package) · [Try it locally](#try-it-locally) · [Integrate with NestJS](#integrate-with-nestjs) · [Reliability evidence](#reliability-evidence) · [Performance](#performance) · [Usage guide](docs/usage.md) · [Testing helpers](docs/testing.md)
|
|
10
|
+
|
|
11
|
+
## Why use Inbox?
|
|
12
|
+
|
|
13
|
+
- **Handle redelivery without repeating committed work.** A stable event identity deduplicates webhook retries and broker redeliveries, including concurrent arrivals.
|
|
14
|
+
- **Keep business state and processing status consistent.** Writes through `context.tx`, optional outbox events, and inbox completion commit or roll back together.
|
|
15
|
+
- **Recover after a worker exits.** PostgreSQL leases, claim tokens, and durable retry timing allow another worker to resume eligible work.
|
|
16
|
+
- **Make failures operable.** Inspect backlog and DEAD records, requeue failures after a fix, or explicitly replay completed messages with an audit trail.
|
|
17
|
+
|
|
18
|
+
Inbox uses your application's existing Prisma client and PostgreSQL database. The core needs no additional cache or broker service. Your application connects to the transport and decides when to ACK, delete a delivery, commit an offset, or return HTTP success.
|
|
19
|
+
|
|
20
|
+
## How it works
|
|
21
|
+
|
|
22
|
+
```mermaid
|
|
23
|
+
flowchart LR
|
|
24
|
+
A["Incoming event"] --> B["accept(): commit receipt"]
|
|
25
|
+
B --> C["Application ACK / HTTP success"]
|
|
26
|
+
B --> D["Worker claims receipt"]
|
|
27
|
+
D --> E["One DB transaction: business writes + completion"]
|
|
28
|
+
E --> F["COMPLETED"]
|
|
29
|
+
E -->|"Failure rolls back"| G["Durable retry or DEAD"]
|
|
30
|
+
G -->|"Retry is due"| D
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The identity is **scope + source + consumer + message ID**. Repeating that identity with the same content returns `duplicate`; different content raises `InboxConflictError`. A duplicate receipt keeps its current processing state.
|
|
34
|
+
|
|
35
|
+
**Acceptance means stored, not processed.** Handler calls may repeat after failures. The guarantee covers successfully committed writes in the supplied transaction while the receipt is retained. External HTTP calls, email, another database, and different event IDs for the same business action need their own idempotency or outbox contract. Message ordering is not guaranteed.
|
|
36
|
+
|
|
37
|
+
## Is it a fit?
|
|
38
|
+
|
|
39
|
+
| Your application needs | What to plan for |
|
|
40
|
+
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
41
|
+
| Reliable webhook or broker consumers on NestJS + Prisma + PostgreSQL | Authenticate the sender, derive the tenant scope, and preserve the producer's event ID across redeliveries. |
|
|
42
|
+
| Atomic database effects and processing completion | Perform every handler write through `context.tx`. Keep external network work outside this transaction. |
|
|
43
|
+
| Multiple workers and recoverable failures | Size the database pool for handler transactions, acceptance, and lease heartbeats; monitor backlog age and DEAD counts. |
|
|
44
|
+
| Tenant-scoped processing and administration | Scope is part of identity. Database RLS requires application policies and a restricted role; an optional tenancy adapter helps apply the context. |
|
|
45
|
+
| A package to evaluate before rollout | This is a pre-1.0 release. Validate sustained production load, database failover, and operational recovery for your deployment. |
|
|
46
|
+
|
|
47
|
+
### Compatibility
|
|
48
|
+
|
|
49
|
+
| Component | Current contract and validation |
|
|
50
|
+
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
51
|
+
| NestJS | `^11.2.8`; tested with 11.2.8 |
|
|
52
|
+
| Prisma client | `^7.10.1`; tested with 7.10.1 and its PostgreSQL driver adapter |
|
|
53
|
+
| PostgreSQL | Integration and crash-recovery tests exercised 16 and 18 |
|
|
54
|
+
| Node.js | Package engine: `>=22.12.0`. Use **22.23.4+ or 24.22.1+** to match the full validation checks and optional tenancy examples. |
|
|
55
|
+
| Modules | CommonJS and dynamic ESM imports checked from an installed tarball |
|
|
56
|
+
| Optional tenancy | `@nestarc/tenancy ^0.16.2`; not required for the core package |
|
|
57
|
+
|
|
58
|
+
The package also declares Nest core, `reflect-metadata`, and `rxjs` peers. See [package.json](package.json) for exact ranges and [validation results](docs/validation.md) for the combinations actually exercised.
|
|
59
|
+
|
|
60
|
+
## Try it locally
|
|
61
|
+
|
|
62
|
+
With a supported Node.js version and Docker running, execute these commands **from this repository checkout**:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npm ci
|
|
66
|
+
npm run test:package
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This builds a tarball, installs it in a separate consumer, starts a disposable PostgreSQL database, and checks types, imports, SQL assets, deduplication, rollback, replay, and scoped administration. A successful run ends with:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
Installed @nestarc/inbox 0.2.0 tarball verification passed.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The runner ignores an existing application `DATABASE_URL`, uses a random local port, and removes its container afterward. The first run may download dependencies and a database image. Docker or database failures fail the check rather than skipping it.
|
|
76
|
+
|
|
77
|
+
To explore the integrations with temporary databases and brokers:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
npm run test:examples
|
|
81
|
+
npm run test:operations
|
|
82
|
+
npm run test:operations:database
|
|
83
|
+
# Install example dependencies once for the RabbitMQ recovery fixture:
|
|
84
|
+
npm install --prefix examples --no-audit --no-fund
|
|
85
|
+
npm run test:broker-recovery
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
That runs all eight [examples](examples/README.md), including an outbox → inbox → business write → outbox chain. You can also follow the examples guide to run one integration at a time.
|
|
89
|
+
|
|
90
|
+
## Reliability evidence
|
|
91
|
+
|
|
92
|
+
The implementation is checked against real databases and installed package artifacts, alongside unit tests.
|
|
93
|
+
|
|
94
|
+
| Failure or integration boundary | Evidence |
|
|
95
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
96
|
+
| Concurrent redelivery | 100 concurrent copies converge on one receipt; changed content is rejected. |
|
|
97
|
+
| Business-write failure | Handler writes, optional outbox writes, and completion roll back together. |
|
|
98
|
+
| Process termination | Child processes are killed with `SIGKILL` after acceptance, after a claim, and after completion update but before commit; recovery is checked against PostgreSQL. |
|
|
99
|
+
| Stale workers | A newer claim token prevents the previous worker from committing business writes or overwriting processing state. |
|
|
100
|
+
| Replay and tenancy | Concurrent replay requests, v1→v2 migration, restricted-role RLS, cross-tenant rejection, and connection context cleanup are checked. |
|
|
101
|
+
| Package installation | Separate consumers verify generated Prisma types, CommonJS/ESM imports, SQL exports, and core use without the optional tenancy package. |
|
|
102
|
+
|
|
103
|
+
Recorded checks for the local Preview.1 baseline include **85 unit tests**, **38 database/crash tests on each of PostgreSQL 16 and 18**, **41 release checks on Node 22 and 24**, and **eight runnable integrations**. GitHub-hosted [CI run 40684612217](https://github.com/nestarc/inbox/actions/runs/40684612217) passed all four Node/PostgreSQL combinations and the examples at commit `664b4fc`. Version 0.1.0 promotes that runtime implementation and schema; the promotion changes version metadata and documentation. Counts describe the recorded runs, not a guarantee of every runtime or failure scenario. [Read the test-to-contract mapping and remaining validation](docs/validation.md).
|
|
104
|
+
|
|
105
|
+
The 0.2.0 follow-up adds **20 testing-helper unit checks**, **three PostgreSQL helper cases**, and actual TCP faults, pool saturation, SIGTERM, PostgreSQL standby promotion, bounded tablespace exhaustion, and RabbitMQ restart/retry/DLQ recovery checks. The [current validation record](docs/validation.md) distinguishes each tested condition from deployment-specific guarantees. Existing 0.1.0 installations keep schema v2 without a migration.
|
|
106
|
+
|
|
107
|
+
A lost commit response is tested by error injection after a real commit. Separate operational tests exercise actual connection loss and blackholes, standby promotion after confirmed WAL replay, business-tablespace ENOSPC, and bounded pool pressure. Automatic failover routing, unreplicated commit loss, WAL-volume exhaustion, and application-specific soak tests remain deployment concerns.
|
|
108
|
+
|
|
109
|
+
## Performance
|
|
110
|
+
|
|
111
|
+
**1,400–2,468 `accept()` calls/second across three local runs**, with the expected business-effect counts and zero observed acceptance or worker errors. These are measurements of **Preview.1 / schema v2**, taken on 28 October 2026. Version 0.1.0 uses the same runtime implementation and schema; the recorded benchmark version remains Preview.1.
|
|
112
|
+
|
|
113
|
+
| Scenario | Receive calls/second | Receive p95 |
|
|
114
|
+
| ---------------------------------------- | -------------------: | ------------: |
|
|
115
|
+
| Fresh table, unique events | 1,400–2,124 | 5.29–11.61 ms |
|
|
116
|
+
| 10,000 retained receipts, unique events | 2,331–2,468 | 4.60–5.28 ms |
|
|
117
|
+
| 10,000 retained receipts, 90% duplicates | 1,813–2,062 | 5.29–6.62 ms |
|
|
118
|
+
|
|
119
|
+
Each range includes all three runs. Across **4,500 calls**, the benchmark verified **3,150 committed business effects** and **1,350 duplicate receipts**.
|
|
120
|
+
|
|
121
|
+
Environment: Apple M1 Pro, Node 24.22.1, PostgreSQL 16.15 in Docker on **tmpfs**, loopback networking, 265-byte payloads, 500 calls per scenario, receive concurrency 8 and worker concurrency 4. Receipt acceptance and worker draining run **separately**. These rates do not represent end-to-end event latency, sustained handler throughput, or a production SLA. Fixed scenario order may warm later cases; the results do not imply that retaining more receipts makes processing faster.
|
|
122
|
+
|
|
123
|
+
The implementation uses partial indexes for due work and expired leases, `SKIP LOCKED` claims, and bounded worker concurrency. Tune concurrency and pool capacity against your own handler and database. Default polling adds scheduling delay beyond the receive latency above.
|
|
124
|
+
|
|
125
|
+
[All measurements, raw JSON, and methodology](docs/benchmarks/2026-10-28-preview1-local.md) are available to inspect. Reproduce the benchmark against a disposable database:
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
npm run benchmark
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
For simultaneous ingress and processing on a disk-backed fixture, run `npm run benchmark:sustained`. The [sustained load report](docs/benchmarks/2026-10-28-sustained-local.md) records overload recovery, pool waiting, hot/cold tenant latency, and payload retention. It is a separate workload from the short acceptance benchmark above.
|
|
132
|
+
|
|
133
|
+
## Integrate with NestJS
|
|
134
|
+
|
|
135
|
+
The following steps target an **existing NestJS 11 application with Prisma 7**, using the versions in the compatibility table above. Your application owns its Prisma client, database migrations, transport credentials, and shutdown lifecycle.
|
|
136
|
+
|
|
137
|
+
### 1. Install the package
|
|
138
|
+
|
|
139
|
+
In your application directory, install Inbox and the adapter matching your Prisma client:
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
npm install @nestarc/inbox@0.2.0
|
|
143
|
+
npm install @prisma/adapter-pg@7.10.1
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
For a local source build, run `npm ci` and `npm pack` in this repository checkout. `prepack` builds the library. Install the resulting tarball in your application instead:
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
npm install /absolute/path/to/inbox/nestarc-inbox-0.2.0.tgz
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### 2. Apply the schema
|
|
153
|
+
|
|
154
|
+
Locate the packaged installation SQL:
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
node -p "require.resolve('@nestarc/inbox/sql/create-inbox-table.sql')"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Add that SQL to your application's migration process and apply it **before starting Inbox**. It creates the inbox tables and indexes. Runtime checks the schema; it never creates or upgrades tables. Keep the SQL in migration history and ensure generated migrations or schema synchronization do not drop the Inbox tables.
|
|
161
|
+
|
|
162
|
+
For the example below, add this application model, generate your Prisma client, and apply its migration too:
|
|
163
|
+
|
|
164
|
+
```prisma
|
|
165
|
+
model OrderProjection {
|
|
166
|
+
tenantId String @map("tenant_id")
|
|
167
|
+
orderId String @map("order_id")
|
|
168
|
+
|
|
169
|
+
@@id([tenantId, orderId])
|
|
170
|
+
@@map("order_projections")
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The composite key also protects the application's business identity if different event IDs refer to the same order. Such a conflict is a handler failure to resolve, not a silently successful Inbox duplicate.
|
|
175
|
+
|
|
176
|
+
**Already using schema v1 from a local development build?** Follow the [schema v2 migration steps](CHANGELOG.md#upgrade-action-schema-version-2) instead of applying the fresh-install SQL.
|
|
177
|
+
|
|
178
|
+
### 3. Register a handler and ingress service
|
|
179
|
+
|
|
180
|
+
In `src/orders.module.ts`, adjust the generated-client import to your Prisma output path. The client below uses your application's `DATABASE_URL`; reuse your existing client if you already provide one.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import "reflect-metadata";
|
|
184
|
+
import { Injectable, Module } from "@nestjs/common";
|
|
185
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
186
|
+
import {
|
|
187
|
+
InboxModule,
|
|
188
|
+
InboxPermanentError,
|
|
189
|
+
InboxService,
|
|
190
|
+
type InboxHandler,
|
|
191
|
+
} from "@nestarc/inbox";
|
|
192
|
+
import { PrismaClient, type Prisma } from "./generated/prisma/client";
|
|
193
|
+
|
|
194
|
+
export const prisma = new PrismaClient({
|
|
195
|
+
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const orderCreated: InboxHandler<
|
|
199
|
+
{ orderId: string },
|
|
200
|
+
Prisma.TransactionClient
|
|
201
|
+
> = {
|
|
202
|
+
consumer: "orders.projection.v1",
|
|
203
|
+
eventType: "order.created",
|
|
204
|
+
schemaVersion: 1,
|
|
205
|
+
validate(payload) {
|
|
206
|
+
return (
|
|
207
|
+
!!payload &&
|
|
208
|
+
typeof payload === "object" &&
|
|
209
|
+
!Array.isArray(payload) &&
|
|
210
|
+
typeof payload.orderId === "string" &&
|
|
211
|
+
payload.orderId.length > 0
|
|
212
|
+
);
|
|
213
|
+
},
|
|
214
|
+
async handle(payload, context) {
|
|
215
|
+
if (context.scope.kind !== "tenant") throw new InboxPermanentError();
|
|
216
|
+
await context.tx.orderProjection.create({
|
|
217
|
+
data: { tenantId: context.scope.id, orderId: payload.orderId },
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
@Injectable()
|
|
223
|
+
export class OrdersIngress {
|
|
224
|
+
constructor(private readonly inbox: InboxService) {}
|
|
225
|
+
|
|
226
|
+
receive(verifiedTenantId: string, event: { id: string; orderId: string }) {
|
|
227
|
+
return this.inbox.accept({
|
|
228
|
+
scope: { kind: "tenant", id: verifiedTenantId },
|
|
229
|
+
source: "orders-service",
|
|
230
|
+
consumer: "orders.projection.v1",
|
|
231
|
+
messageId: event.id,
|
|
232
|
+
eventType: "order.created",
|
|
233
|
+
schemaVersion: 1,
|
|
234
|
+
payload: { orderId: event.orderId },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
@Module({
|
|
240
|
+
imports: [
|
|
241
|
+
InboxModule.forRoot({
|
|
242
|
+
prisma,
|
|
243
|
+
handlers: [orderCreated],
|
|
244
|
+
worker: { enabled: true },
|
|
245
|
+
}),
|
|
246
|
+
],
|
|
247
|
+
providers: [OrdersIngress],
|
|
248
|
+
exports: [OrdersIngress],
|
|
249
|
+
})
|
|
250
|
+
export class OrdersModule {}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Import `OrdersModule` into your application module and inject `OrdersIngress` into your transport consumer. Use a tenant ID derived from authenticated routing or credentials. Reuse the same `event.id` on redelivery.
|
|
254
|
+
|
|
255
|
+
### 4. Acknowledge after acceptance
|
|
256
|
+
|
|
257
|
+
For a RabbitMQ consumer, the application boundary looks like this:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
await ordersIngress.receive(verifiedTenantId, event);
|
|
261
|
+
channel.ack(message);
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Both `accepted` and `duplicate` permit acknowledgment: Inbox now owns processing retries. If acceptance throws or its commit outcome is uncertain, do not ACK; redeliver the same identity. Authentication errors, malformed input, and identity conflicts need a rejection or dead-letter policy. See the [transport examples](examples/README.md) for complete handling.
|
|
265
|
+
|
|
266
|
+
The worker is opt-in and polls every 1,000 ms by default; acceptance does not wait for the handler. Check `InboxAdminService.forTenant(id).get(receipt.id)` or `stats()` for processing progress. Close the Nest application before disconnecting its Prisma client; Inbox does not own that client. For dependency-injected clients and services, use [`forRootAsync`](docs/usage.md#register-a-consumer).
|
|
267
|
+
|
|
268
|
+
## Recovery and integrations
|
|
269
|
+
|
|
270
|
+
Failures retry with persisted exponential backoff and jitter. Permanent errors or exhausted attempts become **DEAD**, where operators can inspect and requeue them with an expected version. An explicit replay creates a **new receipt** for a completed message and preserves the original; it deliberately permits another business execution.
|
|
271
|
+
|
|
272
|
+
Completed payload cleanup preserves the deduplication fingerprint and replay lineage. Deleting receipts or restoring an older database backup reduces the deduplication history. [Retries, retention, administration, and replay](docs/usage.md)
|
|
273
|
+
|
|
274
|
+
| Example | What it demonstrates |
|
|
275
|
+
| ------------------------------- | -------------------------------------------------------------------------- |
|
|
276
|
+
| Outbox | Business writes and a follow-up outbox event in the handler transaction |
|
|
277
|
+
| Signed webhook | Signature validation before tenant mapping and durable acceptance |
|
|
278
|
+
| RabbitMQ | Acceptance before ACK, bounded transport retries, and dead-letter handling |
|
|
279
|
+
| Kafka / SQS-compatible / BullMQ | Acceptance before offset commit, message deletion, or processor completion |
|
|
280
|
+
| Tenancy | Optional `createInboxTenancyContext()` with real PostgreSQL RLS |
|
|
281
|
+
| HTTP idempotency | Inbox deduplication surviving expiry of an HTTP response cache |
|
|
282
|
+
|
|
283
|
+
The Kafka fixture uses Redpanda; the SQS fixture uses ElasticMQ. They validate the examples' protocol composition, not every Kafka deployment or production AWS configuration. [Run and inspect the examples](examples/README.md).
|
|
284
|
+
|
|
285
|
+
## Development and releases
|
|
286
|
+
|
|
287
|
+
```sh
|
|
288
|
+
npm ci
|
|
289
|
+
npm run build
|
|
290
|
+
npm run typecheck
|
|
291
|
+
npm run format:check
|
|
292
|
+
npm test
|
|
293
|
+
npm run test:release
|
|
294
|
+
npm run test:integration
|
|
295
|
+
npm run test:package
|
|
296
|
+
npm run test:examples
|
|
297
|
+
npm run test:operations
|
|
298
|
+
npm run test:operations:database
|
|
299
|
+
# Install example dependencies once for the RabbitMQ recovery fixture:
|
|
300
|
+
npm install --prefix examples --no-audit --no-fund
|
|
301
|
+
npm run test:broker-recovery
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Database checks require Docker. Set `INBOX_TEST_POSTGRES_IMAGE=postgres:18-alpine` to select PostgreSQL 18 for the general fixture; its default is 16. The dedicated `test:operations:database` primary/standby and ENOSPC suite is pinned to PostgreSQL 16.
|
|
305
|
+
|
|
306
|
+
CI and releases share a Node 22/24 × PostgreSQL 16/18 validation workflow. A GitHub Release is configured to publish the exact tested tarball through npm OIDC: prereleases to `next`, release versions such as 0.2.0 to `latest`. A separate Node 24/PostgreSQL 16 gate checks operational recovery. Manual runs only validate and dry-run. [Release setup and publishing procedure](docs/releasing.md)
|
|
307
|
+
|
|
308
|
+
[Changelog](CHANGELOG.md) · [Full usage guide](docs/usage.md) · [Validation record](docs/validation.md) · [Architecture decision](docs/adr/0001-durable-inbox.md) · [Roadmap](docs/implementation-roadmap.md)
|
|
309
|
+
|
|
310
|
+
MIT licensed.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare class InboxError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
constructor(code: string, message: string);
|
|
4
|
+
}
|
|
5
|
+
export declare class InboxValidationError extends InboxError {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare class InboxConflictError extends InboxError {
|
|
9
|
+
constructor();
|
|
10
|
+
}
|
|
11
|
+
export declare class InboxReplayConflictError extends InboxError {
|
|
12
|
+
constructor();
|
|
13
|
+
}
|
|
14
|
+
export declare class InboxPermanentError extends InboxError {
|
|
15
|
+
constructor(message?: string, code?: string);
|
|
16
|
+
}
|
|
17
|
+
export declare class InboxLeaseLostError extends InboxError {
|
|
18
|
+
constructor();
|
|
19
|
+
}
|
|
20
|
+
export declare class InboxStateConflictError extends InboxError {
|
|
21
|
+
constructor();
|
|
22
|
+
}
|
|
23
|
+
export declare class InboxBackpressureError extends InboxError {
|
|
24
|
+
constructor();
|
|
25
|
+
}
|
|
26
|
+
export declare class InboxSchemaError extends InboxError {
|
|
27
|
+
constructor();
|
|
28
|
+
}
|
|
29
|
+
export declare class InboxConfigurationError extends InboxError {
|
|
30
|
+
constructor(message: string);
|
|
31
|
+
}
|
|
32
|
+
export declare class InboxTimeoutError extends InboxError {
|
|
33
|
+
constructor();
|
|
34
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InboxTimeoutError = exports.InboxConfigurationError = exports.InboxSchemaError = exports.InboxBackpressureError = exports.InboxStateConflictError = exports.InboxLeaseLostError = exports.InboxPermanentError = exports.InboxReplayConflictError = exports.InboxConflictError = exports.InboxValidationError = exports.InboxError = void 0;
|
|
4
|
+
class InboxError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = new.target.name;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
exports.InboxError = InboxError;
|
|
13
|
+
class InboxValidationError extends InboxError {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super("INVALID_ENVELOPE", message);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.InboxValidationError = InboxValidationError;
|
|
19
|
+
class InboxConflictError extends InboxError {
|
|
20
|
+
constructor() {
|
|
21
|
+
super("IDENTITY_CONFLICT", "The message identity already exists with different content.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.InboxConflictError = InboxConflictError;
|
|
25
|
+
class InboxReplayConflictError extends InboxError {
|
|
26
|
+
constructor() {
|
|
27
|
+
super("REPLAY_CONFLICT", "The replay request identity already exists with different parameters.");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.InboxReplayConflictError = InboxReplayConflictError;
|
|
31
|
+
class InboxPermanentError extends InboxError {
|
|
32
|
+
constructor(message = "The message cannot be processed.", code = "PERMANENT_ERROR") {
|
|
33
|
+
super(code, message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
exports.InboxPermanentError = InboxPermanentError;
|
|
37
|
+
class InboxLeaseLostError extends InboxError {
|
|
38
|
+
constructor() {
|
|
39
|
+
super("LEASE_LOST", "The processing lease is no longer owned.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
exports.InboxLeaseLostError = InboxLeaseLostError;
|
|
43
|
+
class InboxStateConflictError extends InboxError {
|
|
44
|
+
constructor() {
|
|
45
|
+
super("STATE_CONFLICT", "The record is absent from this scope or its state/version changed.");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
exports.InboxStateConflictError = InboxStateConflictError;
|
|
49
|
+
class InboxBackpressureError extends InboxError {
|
|
50
|
+
constructor() {
|
|
51
|
+
super("BACKPRESSURE", "The configured inbox admission limit has been reached.");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.InboxBackpressureError = InboxBackpressureError;
|
|
55
|
+
class InboxSchemaError extends InboxError {
|
|
56
|
+
constructor() {
|
|
57
|
+
super("SCHEMA_MISMATCH", "Apply the inbox SQL migration before starting the application.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.InboxSchemaError = InboxSchemaError;
|
|
61
|
+
class InboxConfigurationError extends InboxError {
|
|
62
|
+
constructor(message) {
|
|
63
|
+
super("INVALID_CONFIGURATION", message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.InboxConfigurationError = InboxConfigurationError;
|
|
67
|
+
class InboxTimeoutError extends InboxError {
|
|
68
|
+
constructor() {
|
|
69
|
+
super("PROCESSING_TIMEOUT", "The handler exceeded its transaction deadline.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.InboxTimeoutError = InboxTimeoutError;
|
|
73
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/contracts/errors.ts"],"names":[],"mappings":";;;AAAA,MAAa,UAAW,SAAQ,KAAK;IAEjB;IADlB,YACkB,IAAY,EAC5B,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;QAHC,SAAI,GAAJ,IAAI,CAAQ;QAI5B,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC;IAC9B,CAAC;CACF;AARD,gCAQC;AACD,MAAa,oBAAqB,SAAQ,UAAU;IAClD,YAAY,OAAe;QACzB,KAAK,CAAC,kBAAkB,EAAE,OAAO,CAAC,CAAC;IACrC,CAAC;CACF;AAJD,oDAIC;AACD,MAAa,kBAAmB,SAAQ,UAAU;IAChD;QACE,KAAK,CACH,mBAAmB,EACnB,6DAA6D,CAC9D,CAAC;IACJ,CAAC;CACF;AAPD,gDAOC;AACD,MAAa,wBAAyB,SAAQ,UAAU;IACtD;QACE,KAAK,CACH,iBAAiB,EACjB,uEAAuE,CACxE,CAAC;IACJ,CAAC;CACF;AAPD,4DAOC;AACD,MAAa,mBAAoB,SAAQ,UAAU;IACjD,YACE,OAAO,GAAG,kCAAkC,EAC5C,IAAI,GAAG,iBAAiB;QAExB,KAAK,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IACvB,CAAC;CACF;AAPD,kDAOC;AACD,MAAa,mBAAoB,SAAQ,UAAU;IACjD;QACE,KAAK,CAAC,YAAY,EAAE,0CAA0C,CAAC,CAAC;IAClE,CAAC;CACF;AAJD,kDAIC;AACD,MAAa,uBAAwB,SAAQ,UAAU;IACrD;QACE,KAAK,CACH,gBAAgB,EAChB,oEAAoE,CACrE,CAAC;IACJ,CAAC;CACF;AAPD,0DAOC;AACD,MAAa,sBAAuB,SAAQ,UAAU;IACpD;QACE,KAAK,CACH,cAAc,EACd,wDAAwD,CACzD,CAAC;IACJ,CAAC;CACF;AAPD,wDAOC;AACD,MAAa,gBAAiB,SAAQ,UAAU;IAC9C;QACE,KAAK,CACH,iBAAiB,EACjB,gEAAgE,CACjE,CAAC;IACJ,CAAC;CACF;AAPD,4CAOC;AACD,MAAa,uBAAwB,SAAQ,UAAU;IACrD,YAAY,OAAe;QACzB,KAAK,CAAC,uBAAuB,EAAE,OAAO,CAAC,CAAC;IAC1C,CAAC;CACF;AAJD,0DAIC;AACD,MAAa,iBAAkB,SAAQ,UAAU;IAC/C;QACE,KAAK,CACH,oBAAoB,EACpB,gDAAgD,CACjD,CAAC;IACJ,CAAC;CACF;AAPD,8CAOC"}
|