@relaybus/mongodb 0.0.0-stage → 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Berkcan Şavur
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,265 @@
1
- # Temporary Holding Version
1
+ # @relaybus/mongodb
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ MongoDB event store for [relaybus](https://github.com/berkcansavur/relaybus): a transactional
4
+ outbox on multi-document transactions, with atomic leasing and per-key ordering.
5
+
6
+ Events are written in the **same MongoDB transaction** as your business data, so a committed
7
+ change always has its event and a rolled-back change never leaks one. Delivery is at-least-once;
8
+ handlers registered with `transactional: true` also get effectively-once processing, because
9
+ their writes and the delivery's completion commit together.
10
+
11
+ ## Requirements
12
+
13
+ - MongoDB 6.0 or newer, **as a replica set or a sharded cluster**. MongoDB only supports
14
+ transactions there. A single-node replica set is enough for development.
15
+ - Node.js 22.12 or newer, `mongodb` driver 6 or 7 (peer dependency), `@relaybus/core`.
16
+
17
+ A standalone `mongod` is rejected with a `ConfigurationError` that says so (from `setup()` and
18
+ from the first transaction). Start a development replica set with
19
+ `mongod --replSet rs0` followed by `rs.initiate()`, or use the `mongo` Docker image the same way.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ npm install @relaybus/core @relaybus/mongodb mongodb
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```ts
30
+ import { MongoClient } from 'mongodb';
31
+ import { RelayBus, defineEvent } from '@relaybus/core';
32
+ import { MongoEventStore } from '@relaybus/mongodb';
33
+ import type { ClientSession } from 'mongodb';
34
+
35
+ class OrderPlaced extends defineEvent<{ orderId: string }>('order.placed.v1') {}
36
+
37
+ const client = await new MongoClient(process.env.MONGODB_URI!).connect();
38
+ const bus = new RelayBus<ClientSession>({
39
+ store: new MongoEventStore({ client, database: 'shop' }),
40
+ });
41
+
42
+ bus.subscribe(OrderPlaced, async (event) => sendConfirmation(event.payload.orderId), {
43
+ id: 'send-confirmation',
44
+ });
45
+ await bus.start(); // creates collections and indexes, then starts dispatching
46
+
47
+ await bus.transaction(async (session) => {
48
+ await client.db('shop').collection('orders').insertOne({ _id: 'o-1' }, { session });
49
+ await bus.publish(new OrderPlaced({ orderId: 'o-1' }), { key: 'o-1' });
50
+ });
51
+ ```
52
+
53
+ The transaction handle is the driver's `ClientSession`. Pass it to every write that must be
54
+ atomic with the event.
55
+
56
+ ### Using an existing session or Mongoose
57
+
58
+ `publish({ transaction })` and `store.append(session, ...)` accept any `ClientSession` that is
59
+ inside a started transaction, so you are not forced to use `bus.transaction`. A session without a
60
+ started transaction is rejected with a `ConfigurationError`: MongoDB would commit each write
61
+ immediately, silently breaking the atomicity the outbox exists for. Mongoose exposes
62
+ the same driver session (`mongoose.startSession()`, `Model.create([doc], { session })`), and
63
+ `mongoose.connection.getClient()` returns the `MongoClient` to give to the store:
64
+
65
+ ```ts
66
+ const session = await mongoose.startSession();
67
+ await session.withTransaction(async () => {
68
+ await Order.create([{ total: 10 }], { session });
69
+ await bus.publish(new OrderPlaced({ orderId: '1' }), { transaction: session });
70
+ });
71
+ ```
72
+
73
+ relaybus does not depend on Mongoose. `session.withTransaction` may run your callback again
74
+ after a transient error (see [Transactions and retries](#transactions-and-retries)).
75
+
76
+ ### Transactional handlers
77
+
78
+ ```ts
79
+ bus.subscribe(
80
+ OrderPlaced,
81
+ async (event, context) => {
82
+ await projections.insertOne(
83
+ { orderId: event.payload.orderId },
84
+ { session: context.transaction },
85
+ );
86
+ },
87
+ { id: 'projector', transactional: true },
88
+ );
89
+ ```
90
+
91
+ The projection write and the delivery's completion commit together or not at all.
92
+
93
+ ## Options
94
+
95
+ | Option | Default | Meaning |
96
+ | -------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------- |
97
+ | `client` | required | A connected `MongoClient`. The store never closes it. |
98
+ | `database` | the client's default database | Database for the collections. 1-63 characters of letters, digits, `_`, `-`. |
99
+ | `collectionPrefix` | `'relaybus_'` | Collections are `<prefix>events`, `<prefix>deliveries`, `<prefix>key_sequences`. |
100
+ | `transactionOptions` | snapshot read concern, `w: 'majority'`, primary | Passed to `withTransaction`. |
101
+ | `purgeBatchSize` | `500` | Deliveries deleted per purge step. |
102
+ | `generateIdentifier` | UUID v7 | Produces lease tokens. |
103
+
104
+ Non-transactional writes (claims, completions, retries) use write concern `majority` and primary
105
+ reads, whatever the client default is, so a lease survives a failover and the ordering check
106
+ never reads a stale secondary.
107
+
108
+ ## How it works
109
+
110
+ **Collections.** `events` (`_id` = event id), `deliveries` (one per event and handler),
111
+ `key_sequences` (one counter document per ordering key).
112
+
113
+ **Idempotent append.** Event ids are deduplicated by reading what exists inside the transaction
114
+ and writing with upserts. A plain insert would raise a duplicate-key error (11000) when two
115
+ transactions race on the same id, and a duplicate-key error aborts the whole transaction without
116
+ a retry label. An upsert race surfaces as a retryable write conflict instead. The unique index on
117
+ `(eventId, handler)` is a backstop; the code never relies on catching it.
118
+
119
+ **Per-key sequence equals commit order.** Appending a keyed event runs
120
+ `findOneAndUpdate({ _id: key }, { $inc: { value: 1 } }, { upsert: true })` on the counter inside
121
+ the append transaction. A transaction holding an uncommitted increment makes every concurrent
122
+ appender of that key hit a write conflict; the loser restarts after the winner committed and reads
123
+ the next value. Two transactions can never both hold sequence `n`, and a lower sequence is always
124
+ committed before a higher one, so ordering by sequence is ordering by commit.
125
+
126
+ **Claim without `SKIP LOCKED`.** MongoDB has no row locks to skip, so claiming is optimistic:
127
+
128
+ 1. Scan deliveries that are due (`dueAt <= now`) and in the `ready` lane for the requested
129
+ handlers, ordered by `(dueAt, _id)`. `dueAt` is `availableAt` while pending and
130
+ `leaseExpiresAt` while processing; it is removed when a delivery finishes.
131
+ 2. A keyed delivery has to wait in two cases (the rule of the in-memory reference store):
132
+ an **earlier** delivery of the same handler and key is unfinished (`dueAt` present: pending,
133
+ even delayed; processing, even with an expired lease), or **any other** delivery of that
134
+ handler and key is processing under a **live lease** (`leaseExpiresAt > now`). The second
135
+ rule is what keeps a dead letter that an operator requeued from running beside the
136
+ delivery that already took its place.
137
+ 3. Take it with one `findOneAndUpdate` whose filter pins the exact state that was read (status,
138
+ lane, attempt count, `dueAt`). If another claimer changed the delivery in between, the filter
139
+ matches nothing and this claimer moves on. Exactly one claimer wins each delivery.
140
+ 4. Check the first rule again for a keyed delivery that was just leased. Between steps 2 and 3
141
+ a dead letter can be requeued and claimed by another worker whose own check cannot yet see
142
+ this lease. When this second look finds an unfinished predecessor, the lease is undone
143
+ (the delivery is put back exactly as it was selected, a crashed worker's lease included) and
144
+ the claim moves on. The later delivery always gives way, so of two racing claimers exactly
145
+ one keeps running. Together with the live-lease check of step 2 this leaves no interleaving
146
+ in which two deliveries of a handler and key run at once.
147
+
148
+ One claim call never takes two deliveries of the same key, and what it learns about a key (an
149
+ unfinished predecessor, a live lease, its own lease) answers every later delivery of that key
150
+ without another query. The scan continues past blocked deliveries until `limit` is reached, so a
151
+ hot blocked key cannot starve unrelated work.
152
+
153
+ **Parked backlogs.** A key whose head is slow can accumulate thousands of deliveries that are all
154
+ due and all blocked. Scanning them on every claim would cost time proportional to the backlog.
155
+ When one claim finds 32 or more deliveries of a key waiting behind an unfinished predecessor,
156
+ it moves them to the `parked` lane, which the claim index leaves out. At the start of every
157
+ claim, the lowest parked delivery of each key with no unfinished predecessor is moved back to
158
+ `ready`. This is deliberately level-triggered: it looks at the current state, never at an event,
159
+ so a crash while parking, or a predecessor finishing while its followers were being parked, can
160
+ delay a key until the next claim but never strand it. Measured with 50,000 deliveries waiting
161
+ behind a processing head, a claim went from about 520-590 ms to 11-15 ms; the first claim after
162
+ the backlog formed pays about 1.5 s once to park it. Parked deliveries keep their `dueAt`, so
163
+ `statistics().oldestDueAt` still counts them. The per-claim cost of the move-back step is two
164
+ indexed lookups per key that currently has parked deliveries, so keep the number of such
165
+ simultaneously backlogged keys in the hundreds, not millions.
166
+
167
+ `requeue` is an operator action and makes a dead delivery pending again even if later deliveries
168
+ of its key already ran; it blocks the ones that have not been claimed yet, and it cannot start
169
+ while a later delivery of its key is running.
170
+
171
+ **Fencing.** `complete`, `reschedule`, `release`, `bury` and `extendLease` filter by delivery id,
172
+ `status: 'processing'` and the lease token. A worker that lost its lease changes nothing. An expired
173
+ lease nobody has reclaimed yet is still honoured. `complete(lease, now, session)` joins your
174
+ transaction.
175
+
176
+ **Time.** The store uses only the `now` it is given, never the server clock.
177
+
178
+ **Payloads.** Payload and headers are stored as the JSON-normalized value: `Date` becomes an ISO
179
+ string, `undefined` members disappear, as in every other relaybus store. Keys containing `.` or
180
+ `$` are stored as they are (MongoDB 5+). Text with a **lone UTF-16 surrogate** (a broken emoji,
181
+ for example) is rejected with an `EventValidationError`: BSON strings are UTF-8, so MongoDB would
182
+ silently store U+FFFD instead. Run such text through `String.prototype.toWellFormed()` first.
183
+
184
+ **Untrusted input.** The fields of `listDeliveries` and the ids of `requeue` are coerced to
185
+ strings before they reach a query, so an object parsed from an admin request cannot become a
186
+ query operator such as `{ $ne: ... }`.
187
+
188
+ ## Transactions and retries
189
+
190
+ `store.transaction(work)` uses `session.withTransaction`, which restarts the **whole callback**
191
+ after a `TransientTransactionError` (typically a write conflict) and re-sends only the commit after
192
+ an `UnknownTransactionCommitResult`. For everything written through the session this is exactly
193
+ what you want: an aborted attempt leaves nothing behind, and `append` re-reads state at the start
194
+ of every attempt, so a retry never appends twice. The value returned is that of the final attempt.
195
+
196
+ The consequence is that `work` can run more than once, and the same holds for `bus.transaction`,
197
+ which is built on it. Keep side effects that do not go through the session (HTTP calls, other
198
+ databases, message brokers) out of it, or make them idempotent. This fits relaybus: handlers are
199
+ at-least-once anyway, and event publishing is the mechanism meant to replace such side effects.
200
+
201
+ Retries stop after 120 seconds (the driver's limit) with the last error.
202
+
203
+ Transactional handlers run inside such a transaction too, so the same rule applies to them. The
204
+ core does not renew the lease of a transactional handler while it runs (its completion must not
205
+ conflict with a renewal), so the lease must outlast the handler: keep transactional handlers
206
+ short, as the core documentation recommends.
207
+
208
+ ## Indexes
209
+
210
+ Created idempotently by `setup()` (also run by `bus.start()` unless `setupStore: false`):
211
+
212
+ | Collection | Index | Used by |
213
+ | ------------ | ---------------------------------- | ------------------------------------------------ |
214
+ | `deliveries` | `(eventId, handler)` unique | idempotent append, lookup by event |
215
+ | `deliveries` | `(handler, dueAt, _id)` partial | claim scan (`ready` lane only) |
216
+ | `deliveries` | `(handler, key, sequence)` partial | predecessor check of keyed deliveries |
217
+ | `deliveries` | `(handler, key, sequence)` partial | lowest parked delivery of each key (`parked`) |
218
+ | `deliveries` | `(handler, key, dueAt)` partial | live-lease check (`processing` keyed deliveries) |
219
+ | `deliveries` | `(dueAt)` partial | `statistics().oldestDueAt` |
220
+ | `deliveries` | `(completedAt)` partial | purge of completed deliveries |
221
+ | `deliveries` | `(status, createdAt)` | status counts, listing by status |
222
+ | `deliveries` | `(status, updatedAt)` | purge of dead deliveries |
223
+ | `deliveries` | `(handler, createdAt)` | listing by handler |
224
+ | `deliveries` | `(eventType, createdAt)` | listing by event type |
225
+
226
+ `setup()` also creates the three collections explicitly, because collections created implicitly
227
+ inside concurrent transactions conflict with each other. If you manage the schema yourself, create
228
+ the collections and indexes before the first publish.
229
+
230
+ Partial indexes contain only unfinished (or completed) deliveries, so they stay small however much
231
+ history you keep. If you never use `listDeliveries` filters, the three listing indexes can be
232
+ dropped at the cost of slower operator queries.
233
+
234
+ ## Retention
235
+
236
+ `bus.purgeExpired()` (run on a schedule by the bus) calls `store.purge`, which deletes matching
237
+ deliveries in batches of `purgeBatchSize`, each batch together with the events no delivery
238
+ references any more **in one transaction**, so a crash can never leave an event without
239
+ deliveries behind. An append that adds a delivery to an event while it is being purged conflicts
240
+ with the purge and one of them restarts, so a delivery never loses its event; a delivery requeued
241
+ since the batch was read conflicts with its deletion and is read again.
242
+
243
+ One counter document per distinct ordering key stays in `key_sequences`. They are tiny; if your
244
+ keys are unbounded (for example one per request), clean up counters of long-finished keys yourself.
245
+
246
+ ## Troubleshooting
247
+
248
+ - **`MongoEventStore needs multi-document transactions`**: the server is a standalone `mongod`.
249
+ Use a replica set or sharded cluster.
250
+ - **`Transaction numbers are only allowed on a replica set member or mongos`** raised by your own
251
+ code: same cause, for your own transactions.
252
+ - **`TransientTransactionError` / write conflicts under load**: expected when many publishers append
253
+ the same key at once; they serialize through the key's counter and retry. Spread hot keys or batch
254
+ publishes with `publishAll`.
255
+ - **Deliveries stay pending**: check `statistics().oldestDueAt`, and that the process that publishes
256
+ also registered the handler (deliveries are created from the publisher's registrations).
257
+ - **A key is stuck**: a pending, delayed, processing or requeued delivery earlier in the same key
258
+ blocks later ones, and so does any delivery of the key running under a live lease; inspect it
259
+ with `listDeliveries({ handler })` and `bus.requeue` or bury it.
260
+ - **Slow claims with a very large backlog of one key**: backlogs of 32 or more waiting deliveries
261
+ are parked automatically; check that the `claim`, `ordering`, `parked` and `running` indexes
262
+ exist (`setup()`). Deliveries with `lane: 'parked'` are expected while their key is blocked.
263
+ - **`MongoEventStore.append needs a ClientSession inside a started transaction`**: the session
264
+ passed to `publish({ transaction })` or `append` has no transaction. Start one with
265
+ `session.withTransaction` or `session.startTransaction()`.