@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.
- package/README.md +184 -0
- package/dist/batchify.d.ts +36 -0
- package/dist/batchify.d.ts.map +1 -0
- package/dist/batchify.js +57 -0
- package/dist/batchify.js.map +1 -0
- package/dist/db.d.ts +356 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +390 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/outboxRelay.d.ts +34 -0
- package/dist/outboxRelay.d.ts.map +1 -0
- package/dist/outboxRelay.js +152 -0
- package/dist/outboxRelay.js.map +1 -0
- package/dist/types/outbox.d.ts +37 -0
- package/dist/types/outbox.d.ts.map +1 -0
- package/dist/types/outbox.js +22 -0
- package/dist/types/outbox.js.map +1 -0
- package/docs/CLAUDE.md +250 -0
- package/package.json +61 -0
|
@@ -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
|
+
}
|