@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 ADDED
@@ -0,0 +1,184 @@
1
+ # @ahrowe/mongo
2
+
3
+ A typed MongoDB CRUD service factory: `connect()` once, then `createService<T>(...)`
4
+ gives you a collection wrapper with built-in transactions, an event bus
5
+ (`created`/`updated`/`removed`), and optional document validation on writes.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @ahrowe/mongo
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import db from '@ahrowe/mongo';
17
+
18
+ const { createService } = db.connect(process.env.MONGO_DB_URI!, 'myDb');
19
+
20
+ type User = { _id: string; email: string; createdOn?: Date; updatedOn?: Date };
21
+ const users = createService<User>('users');
22
+
23
+ const user = await users.insert({ email: 'a@b.com' });
24
+ const found = await users.findOne({ _id: user._id });
25
+ await users.updateOne({ _id: user._id }, { $set: { email: 'c@d.com' } });
26
+ ```
27
+
28
+ ### Validation on writes
29
+
30
+ Pass a validator function as the second argument to `createService`. It's called on
31
+ every insert and on the document resulting from an update; a non-passing result
32
+ throws. The validator contract is `(entity: T) => { error?: unknown }` — plug in
33
+ whatever validation library you like, or none at all. On a non-empty `error`, that
34
+ value is thrown as-is.
35
+
36
+ With Zod:
37
+
38
+ ```ts
39
+ import { z } from 'zod';
40
+
41
+ const userSchema = z.object({ _id: z.string(), email: z.string().email() });
42
+ const users = createService<User>('users', (entity) => userSchema.safeParse(entity));
43
+ ```
44
+
45
+ Or a plain TS guard with no dependency at all:
46
+
47
+ ```ts
48
+ const users = createService<User>('users', (entity) => (
49
+ entity.email.includes('@') ? {} : { error: new Error('email must contain @') }
50
+ ));
51
+ ```
52
+
53
+ Pass `{ skipValidation: true }` on an individual call to bypass it.
54
+
55
+ ### Events (transactional outbox)
56
+
57
+ ```ts
58
+ users.on('created', ({ doc, meta }) => { /* ... */ });
59
+ users.on('updated', ({ prevDoc, doc, meta }) => { /* ... */ });
60
+ users.on('removed', ({ doc, meta }) => { /* ... */ });
61
+ ```
62
+
63
+ Writes don't dispatch events directly. Every `insert`/`updateOne`/`updateMany`/
64
+ `removeOne`/`removeMany` that actually changes a document writes one row per
65
+ affected document to an internal `outbox` collection, **in the same transaction**
66
+ as the data write. A separate relay process claims and dispatches those rows to
67
+ your `.on(...)` listeners. This makes delivery durable: events survive a crash
68
+ between the write and the listener running, and are never lost or duplicated
69
+ across multiple pods racing the same row (claims are leased atomically).
70
+ `updateOne`/`updateMany` only enqueue `'updated'` for documents that actually
71
+ changed.
72
+
73
+ `updateMany`/`removeMany` always process every matched document individually —
74
+ one atomic operation per document, all inside one transaction, so the whole
75
+ batch still rolls back together on failure — so each outbox row's
76
+ `{ prevDoc, doc }` pair is accurate and no document is missed even if the
77
+ matched set changes mid-operation. Run the relay to drain the outbox:
78
+
79
+ ```ts
80
+ import { startOutboxRelay } from '@ahrowe/mongo';
81
+
82
+ const relay = startOutboxRelay({ instanceId: process.env.HOSTNAME ?? 'local' });
83
+ // relay.stop() on shutdown
84
+ ```
85
+
86
+ The reliable per-document path has **no size cap by default** — but a single
87
+ transaction processing a very large matched set risks hitting MongoDB's
88
+ transaction lifetime limit. Pass `{ maxBatchSize }` (per-call, or as a
89
+ `createService` option for a service-wide default) to enforce one and fail fast
90
+ instead:
91
+
92
+ ```ts
93
+ await users.updateMany({}, { $set: { plan: 'pro' } }, { maxBatchSize: 500 });
94
+ ```
95
+
96
+ Past that limit it throws, pointing you at `bulkWrite` for larger jobs (no
97
+ transaction/validation/event guarantees, but no cap either).
98
+
99
+ Pass `{ meta: {...} }` on a call to thread arbitrary data through to listeners,
100
+ so a specific handler can decide to skip its own side effect without anyone else
101
+ missing the event:
102
+
103
+ ```ts
104
+ await users.updateOne({ _id }, { $set: { email: 'c@d.com' } }, { meta: { dontRegenPdf: true } });
105
+
106
+ users.on('updated', ({ doc, meta }) => {
107
+ if (meta?.dontRegenPdf) return;
108
+ regenerateInvoicePdf(doc);
109
+ });
110
+ ```
111
+
112
+ Infra collections with no listeners (sequences, the outbox itself, etc.) can
113
+ skip outbox writes entirely by passing `{ emitOutboxEvents: false }` to
114
+ `createService` — writes take a leaner fast path with no pre-reads or
115
+ transaction-wrapped outbox insert.
116
+
117
+ If a listener needs request/actor context (e.g. an audit log reading from
118
+ `AsyncLocalStorage`), register a provider once at startup. It's called
119
+ **synchronously at write time** and the captured value is stored on the outbox
120
+ row, so it's still available to the listener even after a process restart:
121
+
122
+ ```ts
123
+ import { setOutboxContextProvider } from '@ahrowe/mongo';
124
+
125
+ setOutboxContextProvider(() => myAsyncLocalStorage.getStore());
126
+ ```
127
+
128
+ ### Transactions
129
+
130
+ ```ts
131
+ import db from '@ahrowe/mongo';
132
+
133
+ const session = await db.startSession();
134
+ await session.withTransaction(async () => {
135
+ await users.insert({ email: 'a@b.com' }, { session });
136
+ await otherService.updateOne({ _id }, { $set: { count: 1 } }, { session });
137
+ });
138
+ ```
139
+
140
+ `insert`/`updateOne`/`updateMany` automatically wrap themselves in a transaction when
141
+ no `session` is passed.
142
+
143
+ ### Connection health
144
+
145
+ ```ts
146
+ const { createService, on } = db.connect(process.env.MONGO_DB_URI!, 'myDb');
147
+
148
+ on('error', ({ source, error }) => { /* 'primary' | 'read' */ });
149
+ on('close', ({ source, error }) => { /* ... */ });
150
+ ```
151
+
152
+ `connect()` wires up `error`/`close` handlers on both the primary and
153
+ secondary-preferred read clients internally (so a connection issue can't crash the
154
+ process), and re-emits them on a connection-level bus you can subscribe to for your
155
+ own alerting.
156
+
157
+ ### Shutdown
158
+
159
+ ```ts
160
+ import db from '@ahrowe/mongo';
161
+
162
+ await db.disconnect();
163
+ ```
164
+
165
+ `disconnect()` closes both MongoDB clients. Outbox rows already committed are
166
+ durable in MongoDB regardless — call this on graceful shutdown (e.g. on
167
+ `SIGTERM`); if you also run `startOutboxRelay` in-process, call `relay.stop()`
168
+ first so it releases any leases it's holding.
169
+
170
+ ## API
171
+
172
+ - `connect(connectionString, databaseName?) → { createService, on }`
173
+ - `createService<T>(collectionName, validate?, { addCreatedOnField?, addUpdatedOnField?, maxLimit?, maxBatchSize?, emitOutboxEvents? }) → DbService<T>`
174
+ - `withSession(cb)`, `startSession(options?)`, `disconnect()`
175
+ - `setOutboxContextProvider(fn)` — capture request/actor context at write time for outbox rows
176
+ - `startOutboxRelay({ instanceId, pollIntervalMs?, batchSize?, leaseMs?, maxAttempts?, autoStart? }) → { stop, tick, drain }`
177
+ - `getServiceBus(collectionName)`, `getRegisteredCollections()`, `getOutboxCollection()` — relay/advanced internals
178
+ - `OutboxOp`, `OutboxStatus`, `OutboxRow`, `OUTBOX_COLLECTION` — outbox row shape, for tooling/inspection
179
+ - `DbService<T>` methods: `find`, `findOne`, `findCursor`, `insert`, `updateOne`,
180
+ `updateMany`, `removeOne`, `removeMany`, `count`, `exists`, `aggregate`, `distinct`,
181
+ `createIndex`, `bulkWrite`, `on`, `onPropertiesUpdated`, `eventBus`, `generateId`, `name`
182
+
183
+ See [docs/CLAUDE.md](docs/CLAUDE.md) for gotchas (upserts are rejected, `findOne`
184
+ strictness, the opt-in `maxLimit`/`maxBatchSize` caps, `bulkWrite` safety gate).
@@ -0,0 +1,36 @@
1
+ interface BatchifyOptions<T> {
2
+ /**
3
+ * Returns the next batch of data, or an array containing all data (sliced into
4
+ * batches automatically). When a function: receives the requested batch size
5
+ * and the number of items already processed.
6
+ */
7
+ dataDelivery?: T[] | ((requestedBatchSize: number, alreadyProcessed: number) => T[] | Promise<T[]>);
8
+ /** Number of items fetched per `dataDelivery` call. Default 1000. */
9
+ batchSize?: number;
10
+ /**
11
+ * Number of items from the current batch processed concurrently. Defaults to
12
+ * `batchSize` (the whole batch at once). Set to `1` for strictly sequential
13
+ * processing — required when items share a MongoDB session/transaction, which
14
+ * doesn't allow concurrent operations.
15
+ */
16
+ concurrency?: number;
17
+ /** Function to run for each item. Receives the item and its global index. */
18
+ functionToRun?: (data: T, index: number) => void | Promise<void>;
19
+ /** Invoked after each batch completes, with the total number of items processed so far. */
20
+ onProgress?: (processedAmount: number) => void | Promise<void>;
21
+ /** Called when processing an item fails. Receives the failed item and the error. */
22
+ onError?: (entry: T, err: unknown) => void | Promise<void>;
23
+ /** If true, stop fetching further batches once any error has occurred. */
24
+ returnOnError?: boolean;
25
+ /**
26
+ * If true, rethrow the first error out of `batchify()` itself once the batch
27
+ * it occurred in has settled, instead of only invoking `onError`. Use this when
28
+ * the caller needs the failure to actually propagate (e.g. to abort a transaction).
29
+ */
30
+ throwOnError?: boolean;
31
+ /** Milliseconds to wait after completing each batch before starting the next. */
32
+ pauseAfterBatch?: number;
33
+ }
34
+ export declare function batchify<T>({ dataDelivery, batchSize, concurrency, functionToRun, onProgress, onError, returnOnError, throwOnError, pauseAfterBatch, }?: BatchifyOptions<T>): Promise<void>;
35
+ export {};
36
+ //# sourceMappingURL=batchify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batchify.d.ts","sourceRoot":"","sources":["../src/batchify.ts"],"names":[],"mappings":"AAAA,UAAU,eAAe,CAAC,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,CAAC,kBAAkB,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,KAAK,CAAC,EAAE,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACpG,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,2FAA2F;IAC3F,UAAU,CAAC,EAAE,CAAC,eAAe,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,oFAAoF;IACpF,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,0EAA0E;IAC1E,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,iFAAiF;IACjF,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAsB,QAAQ,CAAC,CAAC,EAAE,EAChC,YAAuB,EACvB,SAAgB,EAChB,WAAW,EACX,aAA+B,EAC/B,UAA4B,EAC5B,OAAyB,EACzB,aAAqB,EACrB,YAAoB,EACpB,eAAmB,GACpB,GAAE,eAAe,CAAC,CAAC,CAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDzC"}
@@ -0,0 +1,57 @@
1
+ export async function batchify({ dataDelivery = () => [], batchSize = 1000, concurrency, functionToRun = () => undefined, onProgress = () => undefined, onError = () => undefined, returnOnError = false, throwOnError = false, pauseAfterBatch = 0, } = {}) {
2
+ const effectiveConcurrency = concurrency ?? batchSize;
3
+ let iteration = 0;
4
+ let hasError = false;
5
+ let firstError;
6
+ let currentBatch = [];
7
+ const getNextBatch = async () => {
8
+ if (typeof dataDelivery === 'function') {
9
+ currentBatch = await dataDelivery(batchSize, iteration * batchSize);
10
+ }
11
+ else {
12
+ currentBatch = dataDelivery.slice(batchSize * iteration, batchSize + batchSize * iteration);
13
+ }
14
+ };
15
+ const runItem = async (entry, globalIndex) => {
16
+ try {
17
+ await functionToRun(entry, globalIndex);
18
+ }
19
+ catch (error) {
20
+ hasError = true;
21
+ if (throwOnError && !firstError)
22
+ firstError = { error };
23
+ await onError(entry, error);
24
+ }
25
+ };
26
+ const processCurrentBatch = async () => {
27
+ for (let start = 0; start < currentBatch.length; start += effectiveConcurrency) {
28
+ if (firstError)
29
+ return;
30
+ const chunk = currentBatch.slice(start, start + effectiveConcurrency);
31
+ await Promise.allSettled(chunk.map((entry, i) => runItem(entry, batchSize * iteration + start + i)));
32
+ }
33
+ };
34
+ await getNextBatch();
35
+ await onProgress(0);
36
+ while (currentBatch.length) {
37
+ await processCurrentBatch();
38
+ if (firstError)
39
+ throw firstError.error;
40
+ if (currentBatch.length < batchSize) {
41
+ await onProgress(batchSize * iteration + currentBatch.length);
42
+ return;
43
+ }
44
+ iteration += 1;
45
+ await onProgress(batchSize * iteration);
46
+ if (hasError && returnOnError) {
47
+ return;
48
+ }
49
+ await getNextBatch();
50
+ if (currentBatch.length && pauseAfterBatch) {
51
+ await new Promise((resolve) => {
52
+ setTimeout(resolve, pauseAfterBatch);
53
+ });
54
+ }
55
+ }
56
+ }
57
+ //# sourceMappingURL=batchify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batchify.js","sourceRoot":"","sources":["../src/batchify.ts"],"names":[],"mappings":"AAkCA,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAI,EAChC,YAAY,GAAG,GAAG,EAAE,CAAC,EAAE,EACvB,SAAS,GAAG,IAAI,EAChB,WAAW,EACX,aAAa,GAAG,GAAG,EAAE,CAAC,SAAS,EAC/B,UAAU,GAAG,GAAG,EAAE,CAAC,SAAS,EAC5B,OAAO,GAAG,GAAG,EAAE,CAAC,SAAS,EACzB,aAAa,GAAG,KAAK,EACrB,YAAY,GAAG,KAAK,EACpB,eAAe,GAAG,CAAC,MACG,EAAE;IACxB,MAAM,oBAAoB,GAAG,WAAW,IAAI,SAAS,CAAC;IACtD,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,UAA0C,CAAC;IAE/C,IAAI,YAAY,GAAQ,EAAE,CAAC;IAE3B,MAAM,YAAY,GAAG,KAAK,IAAI,EAAE;QAC9B,IAAI,OAAO,YAAY,KAAK,UAAU,EAAE,CAAC;YACvC,YAAY,GAAG,MAAM,YAAY,CAAC,SAAS,EAAE,SAAS,GAAG,SAAS,CAAC,CAAC;QACtE,CAAC;aAAM,CAAC;YACN,YAAY,GAAG,YAAY,CAAC,KAAK,CAAC,SAAS,GAAG,SAAS,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC,CAAC;QAC9F,CAAC;IACH,CAAC,CAAC;IAEF,MAAM,OAAO,GAAG,KAAK,EAAE,KAAQ,EAAE,WAAmB,EAAE,EAAE;QACtD,IAAI,CAAC;YACH,MAAM,aAAa,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC;QAC1C,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,QAAQ,GAAG,IAAI,CAAC;YAChB,IAAI,YAAY,IAAI,CAAC,UAAU;gBAAE,UAAU,GAAG,EAAE,KAAK,EAAE,CAAC;YACxD,MAAM,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QAC9B,CAAC;IACH,CAAC,CAAC;IAEF,MAAM,mBAAmB,GAAG,KAAK,IAAI,EAAE;QACrC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,YAAY,CAAC,MAAM,EAAE,KAAK,IAAI,oBAAoB,EAAE,CAAC;YAC/E,IAAI,UAAU;gBAAE,OAAO;YACvB,MAAM,KAAK,GAAG,YAAY,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,oBAAoB,CAAC,CAAC;YACtE,MAAM,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,GAAG,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QACvG,CAAC;IACH,CAAC,CAAC;IAEF,MAAM,YAAY,EAAE,CAAC;IACrB,MAAM,UAAU,CAAC,CAAC,CAAC,CAAC;IACpB,OAAO,YAAY,CAAC,MAAM,EAAE,CAAC;QAC3B,MAAM,mBAAmB,EAAE,CAAC;QAC5B,IAAI,UAAU;YAAE,MAAM,UAAU,CAAC,KAAK,CAAC;QACvC,IAAI,YAAY,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;YACpC,MAAM,UAAU,CAAC,SAAS,GAAG,SAAS,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;YAC9D,OAAO;QACT,CAAC;QACD,SAAS,IAAI,CAAC,CAAC;QACf,MAAM,UAAU,CAAC,SAAS,GAAG,SAAS,CAAC,CAAC;QACxC,IAAI,QAAQ,IAAI,aAAa,EAAE,CAAC;YAC9B,OAAO;QACT,CAAC;QACD,MAAM,YAAY,EAAE,CAAC;QACrB,IAAI,YAAY,CAAC,MAAM,IAAI,eAAe,EAAE,CAAC;YAC3C,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;gBAC5B,UAAU,CAAC,OAAO,EAAE,eAAe,CAAC,CAAC;YACvC,CAAC,CAAC,CAAC;QACL,CAAC;IACH,CAAC;AACH,CAAC"}