@ahrowe/mongo 0.0.1

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.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Transactional outbox. Every mutation writes one row per affected document in
3
+ * the SAME Mongo transaction as the data write, so an event is persisted iff the
4
+ * mutation committed — it survives a pod death and is delivered after a restart.
5
+ * A restart-safe relay (see `../outboxRelay`) claims pending rows, dispatches
6
+ * them to in-process listeners (at-least-once), and marks them done.
7
+ */
8
+ export declare const OUTBOX_COLLECTION = "outbox";
9
+ export declare enum OutboxOp {
10
+ Created = "created",
11
+ Updated = "updated",
12
+ Removed = "removed"
13
+ }
14
+ export declare enum OutboxStatus {
15
+ Pending = "pending",
16
+ Processing = "processing",
17
+ Done = "done",
18
+ Failed = "failed"
19
+ }
20
+ export interface OutboxRow<T = Record<string, unknown>> {
21
+ _id: string;
22
+ collectionName: string;
23
+ op: OutboxOp;
24
+ doc: T;
25
+ prevDoc?: T | null;
26
+ meta?: Record<string, unknown> | null;
27
+ context?: unknown | null;
28
+ status: OutboxStatus;
29
+ attempts: number;
30
+ nextAttemptAt: Date;
31
+ leasedUntil?: Date | null;
32
+ leasedBy?: string | null;
33
+ lastError?: string | null;
34
+ createdOn: Date;
35
+ processedOn?: Date | null;
36
+ }
37
+ //# sourceMappingURL=outbox.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbox.d.ts","sourceRoot":"","sources":["../../src/types/outbox.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,WAAW,CAAC;AAE1C,oBAAY,QAAQ;IAClB,OAAO,YAAY;IACnB,OAAO,YAAY;IACnB,OAAO,YAAY;CACpB;AAED,oBAAY,YAAY;IACtB,OAAO,YAAY;IACnB,UAAU,eAAe;IACzB,IAAI,SAAS;IACb,MAAM,WAAW;CAClB;AAED,MAAM,WAAW,SAAS,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACpD,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc,EAAE,MAAM,CAAC;IACvB,EAAE,EAAE,QAAQ,CAAC;IACb,GAAG,EAAE,CAAC,CAAC;IACP,OAAO,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,YAAY,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,IAAI,CAAC;IACpB,WAAW,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,EAAE,IAAI,CAAC;IAChB,WAAW,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;CAC3B"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Transactional outbox. Every mutation writes one row per affected document in
3
+ * the SAME Mongo transaction as the data write, so an event is persisted iff the
4
+ * mutation committed — it survives a pod death and is delivered after a restart.
5
+ * A restart-safe relay (see `../outboxRelay`) claims pending rows, dispatches
6
+ * them to in-process listeners (at-least-once), and marks them done.
7
+ */
8
+ export const OUTBOX_COLLECTION = 'outbox';
9
+ export var OutboxOp;
10
+ (function (OutboxOp) {
11
+ OutboxOp["Created"] = "created";
12
+ OutboxOp["Updated"] = "updated";
13
+ OutboxOp["Removed"] = "removed";
14
+ })(OutboxOp || (OutboxOp = {}));
15
+ export var OutboxStatus;
16
+ (function (OutboxStatus) {
17
+ OutboxStatus["Pending"] = "pending";
18
+ OutboxStatus["Processing"] = "processing";
19
+ OutboxStatus["Done"] = "done";
20
+ OutboxStatus["Failed"] = "failed";
21
+ })(OutboxStatus || (OutboxStatus = {}));
22
+ //# sourceMappingURL=outbox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbox.js","sourceRoot":"","sources":["../../src/types/outbox.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,QAAQ,CAAC;AAE1C,MAAM,CAAN,IAAY,QAIX;AAJD,WAAY,QAAQ;IAClB,+BAAmB,CAAA;IACnB,+BAAmB,CAAA;IACnB,+BAAmB,CAAA;AACrB,CAAC,EAJW,QAAQ,KAAR,QAAQ,QAInB;AAED,MAAM,CAAN,IAAY,YAKX;AALD,WAAY,YAAY;IACtB,mCAAmB,CAAA;IACnB,yCAAyB,CAAA;IACzB,6BAAa,CAAA;IACb,iCAAiB,CAAA;AACnB,CAAC,EALW,YAAY,KAAZ,YAAY,QAKvB"}
package/docs/CLAUDE.md ADDED
@@ -0,0 +1,250 @@
1
+ # @ahrowe/mongo — agent guide
2
+
3
+ Typed MongoDB CRUD service factory. `connect()` once, then `createService<T>(collectionName, validate?, options?)`
4
+ gives you a typed wrapper with transactions, a transactional-outbox-backed event
5
+ system (`created`/`updated`/`removed`), and optional document validation on
6
+ insert/update.
7
+
8
+ ESM-only (`"type": "module"`). Single entry point: `@ahrowe/mongo`.
9
+
10
+ ## Setup
11
+
12
+ ```ts
13
+ import db from '@ahrowe/mongo';
14
+
15
+ const { createService, on } = db.connect(process.env.MONGO_DB_URI!, 'myDb');
16
+
17
+ type User = { _id: string; email: string; createdOn?: Date; updatedOn?: Date };
18
+ const users = createService<User>('users');
19
+ ```
20
+
21
+ `connect()` is idempotent — calling it again returns the same `{ createService, on }`
22
+ bound to the already-open connection. It opens **two** clients: a primary and a
23
+ secondary-preferred read client (`preferReadFromSecondary` option on `find`/`findCursor`/
24
+ `count`/`aggregate` routes reads to the latter). Both clients have internal
25
+ `error`/`close` handlers wired up (so a connection issue can't crash the process)
26
+ and re-emit on a connection-level bus: `on('error', ({ source, error }) => ...)` /
27
+ `on('close', ({ source, error }) => ...)`, `source` being `'primary'` or `'read'`.
28
+
29
+ ## Service API
30
+
31
+ Every `createService<T>(...)` call returns:
32
+
33
+ - `find(query, options?)`, `findOne(query, options?)`, `findCursor(query, options?)` —
34
+ reads; `findCursor` enforces no `limit` cap by default — pass `{ maxLimit }`
35
+ (per-call, or as a `createService` option for a service-wide default) to enforce
36
+ one. `findOne` throws if more than one document matches.
37
+ - `insert(doc | doc[], options?)` — generates `_id` and `createdOn` if not supplied;
38
+ runs the schema validator (if any) unless `{ skipValidation: true }`; wraps in a
39
+ transaction unless `{ session }` is passed. Enqueues a `'created'` outbox row
40
+ (in the same transaction as the write) for each inserted document.
41
+ - `updateOne(query, updateBody, options?)` and `updateMany(...)` — **`upsert` is
42
+ rejected**; use `insert` instead. Adds `updatedOn: new Date()` to the `$set`
43
+ unless `addUpdatedOnField: false` was passed to `createService`. Enqueues an
44
+ `'updated'` outbox row with `{ prevDoc, doc, meta }` for each document that
45
+ actually changed. `updateMany` always uses the reliable per-document path (see
46
+ "Events are delivered via a transactional outbox" below).
47
+ - `removeOne(query, options?)` and `removeMany(query, options?)` — `removeMany`
48
+ returns removed docs under `results` unless `{ returnRemoved: false }`. Enqueues
49
+ a `'removed'` outbox row with `{ doc, meta }` for each removed document (no row
50
+ if nothing matched). `removeMany` always uses the reliable per-document path,
51
+ same as `updateMany`.
52
+ - `count`, `exists`, `aggregate`, `distinct`, `createIndex`, `bulkWrite` (requires
53
+ `{ allowBulkwrite: true }` — guards against accidental unvalidated writes).
54
+ `bulkWrite` does not enqueue outbox rows.
55
+ - `on(event, cb)` / `onPropertiesUpdated(properties, cb)` — subscribe to the
56
+ service's own `eventBus` (a plain `node:events` `EventEmitter`). Listeners
57
+ registered here are dispatched to by `startOutboxRelay`, not by the write call
58
+ itself.
59
+ - `withSession(cb)` / `startSession(options?)` — exported from the module (not
60
+ per-service) for manual multi-document transactions across several services.
61
+ - `disconnect()` — exported from the module; closes both MongoDB clients.
62
+ Outbox rows already committed are durable in MongoDB regardless. If you run
63
+ `startOutboxRelay` in-process, call `relay.stop()` first so it releases any
64
+ leases it's holding.
65
+
66
+ ## Validation
67
+
68
+ `createService(name, validate, options)`'s `validate` is `(entity: T) => { error?: unknown }`.
69
+ It's invoked on every insert and on the post-update document; if `error` is set on
70
+ the result, that value is thrown as-is (no formatting or wrapping). Pass
71
+ `{ skipValidation: true }` per-call to bypass it (e.g. for known-good system writes).
72
+
73
+ The contract is validator-agnostic — plug in Zod (`schema.safeParse(entity)`), Joi
74
+ (`schema.validate(entity)`), a plain TS guard, or nothing (`validate: null`).
75
+
76
+ ## Events are delivered via a transactional outbox
77
+
78
+ `insert`/`updateOne`/`updateMany`/`removeOne`/`removeMany` don't dispatch events
79
+ directly — writing to the data collection can't be suppressed per-call, so for
80
+ each document actually affected, one row is written to an internal `outbox`
81
+ collection **in the same transaction** as the data write. A separate process,
82
+ `startOutboxRelay({ instanceId, ... })`, claims pending rows (atomic leased
83
+ `findOneAndUpdate`, safe across multiple pods) and dispatches each to whatever's
84
+ registered via `.on(event, cb)` on that collection's service, retrying with
85
+ backoff and dead-lettering after `maxAttempts`. This means delivery is durable —
86
+ it survives a crash between the write committing and a listener running — and
87
+ at-least-once, so listeners must be idempotent.
88
+
89
+ ```ts
90
+ import { startOutboxRelay } from '@ahrowe/mongo';
91
+
92
+ const relay = startOutboxRelay({
93
+ instanceId: process.env.HOSTNAME ?? 'local', // required — identifies this lease holder
94
+ pollIntervalMs: 500, // idle poll interval when the outbox is empty
95
+ batchSize: 20, // rows claimed+dispatched per tick
96
+ leaseMs: 30_000, // how long a claim is held before another instance may reclaim it
97
+ maxAttempts: 10, // attempts before a row is dead-lettered to 'failed'
98
+ // backoffMs: (attempt) => ms, // override the default capped-exponential backoff
99
+ // autoStart: false, // don't start the poll loop — drive tick()/drain() manually (useful in tests)
100
+ });
101
+ // relay.stop() on shutdown
102
+ ```
103
+
104
+ Run exactly one (or more — it's safe across multiple pods) relay per
105
+ deployment that needs events delivered; nothing dispatches them otherwise.
106
+
107
+ Each listener receives `{ prevDoc, doc, meta, context, eventId }` — not just
108
+ `{ prevDoc, doc, meta }`. `eventId` is the outbox row's `_id`, stable across
109
+ retries of the same row; since delivery is **at-least-once**, use it as an
110
+ idempotency key if your listener's side effect can't safely run twice (e.g.
111
+ upsert a "processed event ids" record, or make the side effect itself
112
+ idempotent on `eventId`).
113
+
114
+ Pass `{ meta: {...} }` on the triggering call to thread arbitrary data through to
115
+ listeners on that event (e.g. a flag a specific handler checks to skip its own
116
+ side effect, without other listeners missing the event).
117
+
118
+ Set `{ emitOutboxEvents: false }` on `createService` for infra collections with
119
+ no listeners (sequences, the outbox itself, etc.) — writes then take a leaner
120
+ fast path with no pre-reads and no transaction-wrapped outbox insert.
121
+
122
+ If a listener needs request/actor context (e.g. an audit log), register
123
+ `setOutboxContextProvider(fn)` once at startup. It's called **synchronously at
124
+ write time**, in the same transaction as the data write, and the captured value
125
+ is stored on the outbox row's `context` field — available to your listener even
126
+ after a process restart, unlike a live `AsyncLocalStorage` read (which would not
127
+ survive the relay dispatching asynchronously, possibly from a different process).
128
+
129
+ ### Inspecting and recovering dead-lettered rows
130
+
131
+ A row that keeps failing past `maxAttempts` is marked `OutboxStatus.Failed` and
132
+ the relay stops retrying it automatically — it's left for manual inspection.
133
+ There's no built-in tooling for this; query/replay it directly:
134
+
135
+ ```ts
136
+ import { getOutboxCollection, OutboxStatus } from '@ahrowe/mongo';
137
+
138
+ const failed = await getOutboxCollection().find({ status: OutboxStatus.Failed }).toArray();
139
+ // inspect failed[i].lastError, fix the underlying issue, then requeue:
140
+ await getOutboxCollection().updateOne(
141
+ { _id: failed[0]._id },
142
+ { $set: { status: OutboxStatus.Pending, nextAttemptAt: new Date(), attempts: 0, lastError: null } },
143
+ );
144
+ ```
145
+
146
+ `getServiceBus(collectionName)` and `getRegisteredCollections()` are the same
147
+ internals the relay itself uses to route a row to its listeners — reach for
148
+ them only if you're building custom tooling around the outbox (a dashboard, a
149
+ manual replay script); normal listener registration is just `.on(...)` on the
150
+ service.
151
+
152
+ ### Multiple consumers
153
+
154
+ The relay uses **competing-consumer** claiming: each outbox row is claimed
155
+ atomically by exactly one relay instance, dispatched to all `.on(...)` listeners
156
+ registered in **that process**, then marked done. This is the right model when
157
+ all consumers run in the same process (e.g. `@ahrowe/audit-log` bundled into
158
+ the main app alongside the relay) — `.on(...)` from multiple packages in the
159
+ same process all fire for every row.
160
+
161
+ It is **not** pub/sub broadcast across separately-deployed services. If a second
162
+ independent consumer service ever needs to receive the same events, the standard
163
+ move is to bridge: run one relay whose only job is to republish each row to a
164
+ message broker (Kafka, SNS, SQS, etc.), and let downstream services subscribe
165
+ there. The outbox guarantees the write reaches the broker exactly once; the
166
+ broker handles fan-out from there. That bridging code lives in your app — this
167
+ package has no broker dependency by design.
168
+
169
+ Until a second independent consumer appears, don't add the broker. Bundle the
170
+ relay and all listeners in the same process.
171
+
172
+ ### Outbox row retention
173
+
174
+ The relay creates a TTL index that drops `done` (successfully processed) rows
175
+ **7 days** after `processedOn` is set. `failed` rows keep `processedOn: null`
176
+ and are **never** auto-deleted — they accumulate until manually requeued or
177
+ removed, so a steady stream of permanently-failing events will grow the outbox
178
+ collection unbounded until someone looks at it.
179
+
180
+ ## updateMany/removeMany always use the reliable per-document path
181
+
182
+ Unlike a single native bulk `updateMany`/`deleteMany` call, both methods process
183
+ every matched document one at a time, sequentially (MongoDB doesn't allow
184
+ concurrent operations on one session), inside a single transaction so the whole
185
+ batch still rolls back together (data **and** outbox rows) on any failure. Every
186
+ document is individually validated, and each outbox row's `{ prevDoc, doc }` pair
187
+ comes from its own atomic `findOneAndUpdate`/`findOneAndDelete` — not a diff
188
+ between two independent before/after scans, which could otherwise miss or
189
+ misattribute documents if the matched set changed mid-operation.
190
+
191
+ This path has **no size cap by default** — pass `{ maxBatchSize }` (per-call, or
192
+ as a `createService` option) to enforce one and fail fast; a single transaction
193
+ processing a very large matched set risks hitting MongoDB's transaction lifetime
194
+ limit (default 60s). Past that limit it throws — use `bulkWrite` for larger jobs
195
+ (no transaction/validation/event guarantees, but no cap either).
196
+
197
+ Internally this is powered by `src/batchify.ts` (sequential `concurrency: 1`,
198
+ `throwOnError: true` so a per-document failure aborts the whole transaction) — an
199
+ internal implementation detail, not exported from the package.
200
+
201
+ ## Transactions
202
+
203
+ `insert`/`updateOne`/`updateMany` each wrap themselves in a transaction automatically
204
+ when no `{ session }` is given (via `client.withSession` + `session.withTransaction`).
205
+ Pass an explicit `session` (from `startSession()` or `withSession()`) to compose
206
+ multiple service calls — even across different `createService(...)` instances/collections
207
+ — into one atomic transaction.
208
+
209
+ ## Gotchas (read before integrating)
210
+
211
+ - **No upserts.** `updateOne`/`updateMany` throw if `options.upsert` is set — this is
212
+ intentional; call `insert` for the create path.
213
+ - **`findOne` is strict.** It throws (not silently returns the first match) if the
214
+ query matches more than one document.
215
+ - **`findCursor`/`find` have no `limit` cap by default.** Pass `{ maxLimit }`
216
+ (per-call or via `createService`) to enforce one — a higher `limit` then throws
217
+ synchronously.
218
+ - **Event delivery is asynchronous and decoupled from the write call** — the
219
+ caller of `insert`/`updateOne`/etc. never sees a listener's error or knows
220
+ when (or whether yet) a listener has run; only the outbox row's commit is
221
+ awaited. A throwing/rejecting listener doesn't crash the relay — that row is
222
+ retried with backoff and dead-lettered after `maxAttempts`. Nothing dispatches
223
+ events unless an outbox relay (`startOutboxRelay`) is actually running
224
+ somewhere in the deployment.
225
+ - **Two MongoDB clients per `connect()` call** (primary + secondary-preferred reader).
226
+ `preferReadFromSecondary: true` only affects `find`/`findCursor`/`count`/`aggregate`.
227
+ - **`bulkWrite` requires `{ allowBulkwrite: true }`** — there is no other safety net
228
+ (no schema validation runs on bulk writes).
229
+ - **`updateOne`'s `prevDoc` can be stale** — and so can `updateMany`/`removeMany`'s
230
+ reliable path, since it reuses the exact same single-document logic
231
+ (`performSingleUpdate` in `db.ts`). To populate `{ prevDoc, doc }` on the outbox
232
+ row, the document is read with a separate `findOne` *before* running the atomic
233
+ `findOneAndUpdate` — a concurrent write landing in that gap means `prevDoc` may
234
+ not reflect the actual immediately-prior state. The atomic update itself is
235
+ unaffected (Mongo still applies it correctly); only the `prevDoc` value in the
236
+ outbox row can be stale. Treat `prevDoc` as best-effort, not authoritative, for
237
+ anything safety-critical.
238
+ - **`emitOutboxEvents: false` (no-outbox) writes only validate one sampled
239
+ document on `updateMany`, not every updated document.** For `$set`-with-literal-values
240
+ updates this is representative (every matched doc lands on the same value for
241
+ the touched fields), but for operators whose result depends on a document's
242
+ prior value (`$inc`, `$mul`, `$min`, `$max`, `$push`/`$addToSet`, `$rename`,
243
+ positional array filters), the same update can be valid for the sampled
244
+ document and invalid for another — undetected on that fast path. The default
245
+ reliable path (`emitOutboxEvents: true`, the default) validates every document
246
+ individually and doesn't have this gap.
247
+ - **Nothing dispatches outbox events unless a relay is running.** Outbox rows
248
+ accumulate (durably, harmlessly) until some process calls `startOutboxRelay`.
249
+ Make sure at least one instance in your deployment runs it, or `.on(...)`
250
+ listeners will never fire.
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@ahrowe/mongo",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "Typed MongoDB CRUD service factory with built-in transactions, event hooks, and optional document validation.",
7
+ "keywords": [
8
+ "mongodb",
9
+ "mongo",
10
+ "crud",
11
+ "transactions"
12
+ ],
13
+ "author": "AndreasWeinzierl",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/AndreasWeinzierl/ahrowe-mongo"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/AndreasWeinzierl/ahrowe-mongo/issues"
20
+ },
21
+ "homepage": "https://github.com/AndreasWeinzierl/ahrowe-mongo#readme",
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "files": [
25
+ "dist",
26
+ "docs/CLAUDE.md"
27
+ ],
28
+ "sideEffects": false,
29
+ "exports": {
30
+ ".": {
31
+ "import": "./dist/index.js",
32
+ "types": "./dist/index.d.ts"
33
+ }
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "lodash": "^4.17.21",
40
+ "mongodb": "^7.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@eslint/js": "^10.0.1",
44
+ "@faker-js/faker": "^10.1.0",
45
+ "@types/lodash": "^4.17.21",
46
+ "@types/node": "^24.13.2",
47
+ "eslint": "^10.5.0",
48
+ "globals": "^17.6.0",
49
+ "testcontainers": "^11.9.0",
50
+ "typescript": "^6.0.3",
51
+ "typescript-eslint": "^8.61.0",
52
+ "vitest": "^4.1.8"
53
+ },
54
+ "scripts": {
55
+ "prebuild": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
56
+ "build": "tsc",
57
+ "lint": "eslint .",
58
+ "test": "vitest",
59
+ "release": "bash scripts/release.sh"
60
+ }
61
+ }