@ahrowe/mongo 0.1.3 → 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 +19 -0
- package/README.md +45 -19
- package/dist/db.d.ts +31 -13
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +140 -119
- package/dist/db.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/outboxRelay.d.ts +18 -26
- package/dist/outboxRelay.d.ts.map +1 -1
- package/dist/outboxRelay.js +35 -46
- package/dist/outboxRelay.js.map +1 -1
- package/dist/types/outbox.d.ts +13 -2
- package/dist/types/outbox.d.ts.map +1 -1
- package/dist/types/outbox.js +4 -1
- package/dist/types/outbox.js.map +1 -1
- package/docs/CLAUDE.md +86 -82
- package/package.json +5 -2
- package/dist/batchify.d.ts +0 -36
- package/dist/batchify.d.ts.map +0 -1
- package/dist/batchify.js +0 -57
- package/dist/batchify.js.map +0 -1
package/dist/outboxRelay.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { getOutboxCollection, getRegisteredCollections, getServiceBus } from './db.js';
|
|
1
|
+
import { dispatchEvent, getOutboxCollection, getRegisteredCollections } from './db.js';
|
|
3
2
|
import { OutboxStatus } from './types/outbox.js';
|
|
4
3
|
const logger = {
|
|
5
4
|
info: console.info,
|
|
@@ -11,34 +10,33 @@ const defaultBackoff = (attempt) => Math.min(30000, 1000 * 2 ** Math.max(0, atte
|
|
|
11
10
|
/** Rows past their retention are removed by the TTL index on `expireAt`. `null` keeps a row forever. */
|
|
12
11
|
const expiryAfter = (keepMs) => (Number.isFinite(keepMs) ? new Date(Date.now() + keepMs) : null);
|
|
13
12
|
/**
|
|
14
|
-
* Due rows
|
|
15
|
-
* A row for any other collection waits for a relay
|
|
16
|
-
* being marked done with nobody notified. Exported for the
|
|
13
|
+
* Due rows for collections this process has a service for. A claimed row whose lease ran out is
|
|
14
|
+
* due again, so it needs no condition of its own. A row for any other collection waits for a relay
|
|
15
|
+
* that has its listeners, instead of being marked done with nobody notified. Exported for the
|
|
16
|
+
* index test, not part of the public barrel.
|
|
17
17
|
*/
|
|
18
18
|
export const claimFilter = (now, collectionNames) => ({
|
|
19
19
|
collectionName: { $in: collectionNames },
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
{ status: OutboxStatus.Processing, leasedUntil: { $lt: now } },
|
|
23
|
-
],
|
|
20
|
+
status: OutboxStatus.Pending,
|
|
21
|
+
nextAttemptAt: { $lte: now },
|
|
24
22
|
});
|
|
25
23
|
/**
|
|
26
|
-
* Longest-due first. nextAttemptAt (equal to createdOn for a fresh row) is in
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* per claimed row.
|
|
24
|
+
* Longest-due first. nextAttemptAt (equal to createdOn for a fresh row) is in the status index,
|
|
25
|
+
* so the rows come back pre-sorted. Sorting by createdOn instead read and sorted every due row in
|
|
26
|
+
* memory, once per claimed row.
|
|
30
27
|
*/
|
|
31
28
|
export const CLAIM_SORT = { nextAttemptAt: 1 };
|
|
32
29
|
/**
|
|
33
30
|
* Drains the transactional outbox and dispatches each event to the in-process
|
|
34
31
|
* listeners registered on its source collection's bus.
|
|
35
32
|
*
|
|
36
|
-
* Restart-safe and multi-pod-safe:
|
|
37
|
-
*
|
|
38
|
-
* at-least-once (consumers must
|
|
33
|
+
* Restart-safe and multi-pod-safe: a claim is an atomic findOneAndUpdate that
|
|
34
|
+
* pushes the row's nextAttemptAt past a lease, so a row whose holder died is
|
|
35
|
+
* due again once the lease runs out. Delivery is at-least-once (consumers must
|
|
36
|
+
* be idempotent).
|
|
39
37
|
*/
|
|
40
38
|
export const startOutboxRelay = (options) => {
|
|
41
|
-
const { instanceId, pollIntervalMs = 500, batchSize = 20, leaseMs = 30000, maxAttempts = 10, backoffMs = defaultBackoff, keepDoneMs = 7 * 24 * 60 * 60 * 1000, keepFailedMs =
|
|
39
|
+
const { instanceId, pollIntervalMs = 500, batchSize = 20, leaseMs = 30000, maxAttempts = 10, backoffMs = defaultBackoff, keepDoneMs = 7 * 24 * 60 * 60 * 1000, keepFailedMs = 90 * 24 * 60 * 60 * 1000, autoStart = true, } = options;
|
|
42
40
|
let running = true;
|
|
43
41
|
let loopPromise = null;
|
|
44
42
|
let indexesReady = null;
|
|
@@ -46,15 +44,10 @@ export const startOutboxRelay = (options) => {
|
|
|
46
44
|
if (!indexesReady) {
|
|
47
45
|
const col = getOutboxCollection();
|
|
48
46
|
indexesReady = (async () => {
|
|
49
|
-
await col.createIndex({ status: 1, nextAttemptAt: 1
|
|
47
|
+
await col.createIndex({ status: 1, nextAttemptAt: 1 });
|
|
50
48
|
// Retention is per row: markDone/markFailure set expireAt, so changing keepDoneMs
|
|
51
49
|
// needs no index change (createIndex can't alter expireAfterSeconds of an existing index).
|
|
52
50
|
await col.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 });
|
|
53
|
-
// v0.1.x expired done rows 7 days after processedOn, which would override keepDoneMs.
|
|
54
|
-
await col.dropIndex('processedOn_1').catch((err) => {
|
|
55
|
-
if (err?.codeName !== 'IndexNotFound')
|
|
56
|
-
throw err;
|
|
57
|
-
});
|
|
58
51
|
})().catch((err) => {
|
|
59
52
|
// Don't cache a failure: the next tick tries again instead of failing forever.
|
|
60
53
|
indexesReady = null;
|
|
@@ -67,37 +60,18 @@ export const startOutboxRelay = (options) => {
|
|
|
67
60
|
const col = getOutboxCollection();
|
|
68
61
|
const now = new Date();
|
|
69
62
|
const claimed = await col.findOneAndUpdate(claimFilter(now, getRegisteredCollections()), {
|
|
70
|
-
$set: {
|
|
71
|
-
status: OutboxStatus.Processing,
|
|
72
|
-
leasedBy: instanceId,
|
|
73
|
-
leasedUntil: new Date(now.getTime() + leaseMs),
|
|
74
|
-
},
|
|
63
|
+
$set: { leasedBy: instanceId, nextAttemptAt: new Date(now.getTime() + leaseMs) },
|
|
75
64
|
$inc: { attempts: 1 },
|
|
76
65
|
}, { sort: CLAIM_SORT, returnDocument: 'after' });
|
|
77
66
|
return claimed ?? null;
|
|
78
67
|
};
|
|
79
|
-
const dispatch = async (row) => {
|
|
80
|
-
const bus = getServiceBus(row.collectionName);
|
|
81
|
-
// claimFilter only matches registered collections, so this can't happen. Never mark such a row done.
|
|
82
|
-
if (!bus)
|
|
83
|
-
throw new Error(`No service registered for collection '${row.collectionName}'`);
|
|
84
|
-
const payload = {
|
|
85
|
-
prevDoc: row.prevDoc ?? undefined,
|
|
86
|
-
doc: row.doc,
|
|
87
|
-
meta: row.meta ?? undefined,
|
|
88
|
-
context: row.context ?? undefined,
|
|
89
|
-
eventId: row._id,
|
|
90
|
-
};
|
|
91
|
-
const listeners = bus.listeners(row.op);
|
|
92
|
-
await Promise.all(listeners.map(async (l) => l(payload)));
|
|
93
|
-
};
|
|
94
68
|
/**
|
|
95
69
|
* Writes a claimed row's outcome, but only while this claim still holds it. attempts goes up
|
|
96
70
|
* on every claim, so a holder whose lease ran out (and whose row another instance reclaimed)
|
|
97
71
|
* matches nothing instead of overwriting the newer claim's outcome.
|
|
98
72
|
*/
|
|
99
73
|
const settle = async (row, set) => {
|
|
100
|
-
const { matchedCount } = await getOutboxCollection().updateOne({ _id: row._id, leasedBy: instanceId, attempts: row.attempts }, { $set: { ...set, leasedBy: null
|
|
74
|
+
const { matchedCount } = await getOutboxCollection().updateOne({ _id: row._id, leasedBy: instanceId, attempts: row.attempts }, { $set: { ...set, leasedBy: null } });
|
|
101
75
|
if (matchedCount === 0) {
|
|
102
76
|
logger.warn(`Outbox row ${row._id} (${row.collectionName}/${row.op}) was reclaimed after its lease ran out. ` +
|
|
103
77
|
'Raise leaseMs above your slowest listener.');
|
|
@@ -122,8 +96,21 @@ export const startOutboxRelay = (options) => {
|
|
|
122
96
|
}
|
|
123
97
|
};
|
|
124
98
|
const processRow = async (row) => {
|
|
99
|
+
// markFailure dead-letters at maxAttempts, so only claims whose lease ran out get past it: a
|
|
100
|
+
// listener that crashes the process or outlasts leaseMs. Dispatching again would repeat that.
|
|
101
|
+
if (row.attempts > maxAttempts) {
|
|
102
|
+
await markFailure(row, new Error(`lease of ${leaseMs}ms ran out on earlier attempts; not dispatched again`));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
125
105
|
try {
|
|
126
|
-
await
|
|
106
|
+
await dispatchEvent(row.collectionName, {
|
|
107
|
+
op: row.op,
|
|
108
|
+
eventId: row._id,
|
|
109
|
+
doc: row.doc,
|
|
110
|
+
prevDoc: row.prevDoc,
|
|
111
|
+
meta: row.meta,
|
|
112
|
+
context: row.context,
|
|
113
|
+
});
|
|
127
114
|
await markDone(row);
|
|
128
115
|
}
|
|
129
116
|
catch (err) {
|
|
@@ -171,7 +158,9 @@ export const startOutboxRelay = (options) => {
|
|
|
171
158
|
if (loopPromise)
|
|
172
159
|
await loopPromise;
|
|
173
160
|
try {
|
|
174
|
-
|
|
161
|
+
// A held row's nextAttemptAt is its lease end, still ahead: the condition keeps this off the due backlog.
|
|
162
|
+
const now = new Date();
|
|
163
|
+
await getOutboxCollection().updateMany({ status: OutboxStatus.Pending, nextAttemptAt: { $gt: now }, leasedBy: instanceId }, { $set: { leasedBy: null, nextAttemptAt: now } });
|
|
175
164
|
}
|
|
176
165
|
catch (err) {
|
|
177
166
|
logger.error('Outbox relay failed to release leased rows on stop:', err);
|
package/dist/outboxRelay.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"outboxRelay.js","sourceRoot":"","sources":["../src/outboxRelay.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"outboxRelay.js","sourceRoot":"","sources":["../src/outboxRelay.ts"],"names":[],"mappings":"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"}
|
package/dist/types/outbox.d.ts
CHANGED
|
@@ -11,12 +11,24 @@ export declare enum OutboxOp {
|
|
|
11
11
|
Updated = "updated",
|
|
12
12
|
Removed = "removed"
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* A claimed row stays `pending`: the claim pushes `nextAttemptAt` past its lease, so a row whose
|
|
16
|
+
* holder died is due again through the same query that finds new rows.
|
|
17
|
+
*/
|
|
14
18
|
export declare enum OutboxStatus {
|
|
15
19
|
Pending = "pending",
|
|
16
|
-
Processing = "processing",
|
|
17
20
|
Done = "done",
|
|
18
21
|
Failed = "failed"
|
|
19
22
|
}
|
|
23
|
+
/** What `dispatchEvent` delivers, and listeners receive. `eventId` is the outbox row's `_id`. */
|
|
24
|
+
export interface OutboxEvent {
|
|
25
|
+
op: `${OutboxOp}`;
|
|
26
|
+
eventId: string;
|
|
27
|
+
doc: unknown;
|
|
28
|
+
prevDoc?: unknown;
|
|
29
|
+
meta?: Record<string, unknown> | null;
|
|
30
|
+
context?: unknown;
|
|
31
|
+
}
|
|
20
32
|
export interface OutboxRow<T = Record<string, unknown>> {
|
|
21
33
|
_id: string;
|
|
22
34
|
collectionName: string;
|
|
@@ -28,7 +40,6 @@ export interface OutboxRow<T = Record<string, unknown>> {
|
|
|
28
40
|
status: OutboxStatus;
|
|
29
41
|
attempts: number;
|
|
30
42
|
nextAttemptAt: Date;
|
|
31
|
-
leasedUntil?: Date | null;
|
|
32
43
|
leasedBy?: string | null;
|
|
33
44
|
lastError?: string | null;
|
|
34
45
|
createdOn: Date;
|
|
@@ -1 +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,
|
|
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;;;GAGG;AACH,oBAAY,YAAY;IACtB,OAAO,YAAY;IACnB,IAAI,SAAS;IACb,MAAM,WAAW;CAClB;AAED,iGAAiG;AACjG,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,GAAG,QAAQ,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,OAAO,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACtC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;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,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;IAC1B,QAAQ,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;CACxB"}
|
package/dist/types/outbox.js
CHANGED
|
@@ -12,10 +12,13 @@ export var OutboxOp;
|
|
|
12
12
|
OutboxOp["Updated"] = "updated";
|
|
13
13
|
OutboxOp["Removed"] = "removed";
|
|
14
14
|
})(OutboxOp || (OutboxOp = {}));
|
|
15
|
+
/**
|
|
16
|
+
* A claimed row stays `pending`: the claim pushes `nextAttemptAt` past its lease, so a row whose
|
|
17
|
+
* holder died is due again through the same query that finds new rows.
|
|
18
|
+
*/
|
|
15
19
|
export var OutboxStatus;
|
|
16
20
|
(function (OutboxStatus) {
|
|
17
21
|
OutboxStatus["Pending"] = "pending";
|
|
18
|
-
OutboxStatus["Processing"] = "processing";
|
|
19
22
|
OutboxStatus["Done"] = "done";
|
|
20
23
|
OutboxStatus["Failed"] = "failed";
|
|
21
24
|
})(OutboxStatus || (OutboxStatus = {}));
|
package/dist/types/outbox.js.map
CHANGED
|
@@ -1 +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,
|
|
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;;;GAGG;AACH,MAAM,CAAN,IAAY,YAIX;AAJD,WAAY,YAAY;IACtB,mCAAmB,CAAA;IACnB,6BAAa,CAAA;IACb,iCAAiB,CAAA;AACnB,CAAC,EAJW,YAAY,KAAZ,YAAY,QAIvB"}
|
package/docs/CLAUDE.md
CHANGED
|
@@ -49,13 +49,14 @@ Every `createService<T>(...)` call returns:
|
|
|
49
49
|
is rejected**; use `upsertOne` for create-or-update, `insert` for explicit creates. Adds `updatedOn: new Date()` to the `$set`
|
|
50
50
|
unless `addUpdatedOnField: false` was passed to `createService`. Enqueues an
|
|
51
51
|
`'updated'` outbox row with `{ prevDoc, doc, meta }` for each document that
|
|
52
|
-
actually changed. `updateMany
|
|
53
|
-
"Events are delivered via a transactional outbox" below).
|
|
52
|
+
actually changed. For `updateMany`, see "How updateMany/removeMany work" below.
|
|
54
53
|
- `removeOne(query, options?)` and `removeMany(query, options?)` — `removeMany`
|
|
55
54
|
returns removed docs under `results` unless `{ returnRemoved: false }`. Enqueues
|
|
56
55
|
a `'removed'` outbox row with `{ doc, meta }` for each removed document (no row
|
|
57
|
-
if nothing matched). `removeMany
|
|
58
|
-
|
|
56
|
+
if nothing matched). For `removeMany`, see "How updateMany/removeMany work" below.
|
|
57
|
+
- Write methods reject `projection` and `includeResultMetadata` (types omit them,
|
|
58
|
+
and they throw at runtime): validation and outbox events need the full
|
|
59
|
+
document. Read the fields you need with `find`/`findOne` afterwards.
|
|
59
60
|
- `createIndex(fields, options?)` — `fields` is `IndexSpec<T>`: any top-level key of
|
|
60
61
|
`T` or a dotted path into it (`'users._id'`), following arrays into their element
|
|
61
62
|
type, mapped to `IndexDirection` (`1 | -1 | 'text' | 'hashed' | '2dsphere' | '2d'`).
|
|
@@ -65,12 +66,13 @@ Every `createService<T>(...)` call returns:
|
|
|
65
66
|
having to enumerate that far.
|
|
66
67
|
- `count`, `exists`, `aggregate`, `distinct`, `dropIndex`, `bulkWrite`
|
|
67
68
|
(requires `{ allowBulkwrite: true }` — guards against accidental unvalidated writes).
|
|
68
|
-
`bulkWrite`
|
|
69
|
+
`bulkWrite` writes no outbox rows, so it throws on a service that emits them.
|
|
70
|
+
`dropIndex(indexName, options?)` drops
|
|
69
71
|
a single index by name (not by key spec) and does not enqueue outbox rows.
|
|
70
|
-
- `on(event, cb)` / `onPropertiesUpdated(properties, cb)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
itself.
|
|
72
|
+
- `on(event, cb)` / `onPropertiesUpdated(properties, cb)`: register a listener
|
|
73
|
+
for `'created' | 'updated' | 'removed'` on this collection. Returns a function
|
|
74
|
+
that removes it. Listeners are called by `startOutboxRelay` (or
|
|
75
|
+
`dispatchEvent`), not by the write call itself.
|
|
74
76
|
- `withSession(cb)` / `startSession(options?)` — exported from the module (not
|
|
75
77
|
per-service) for manual multi-document transactions across several services.
|
|
76
78
|
- `disconnect()` — exported from the module; closes both MongoDB clients.
|
|
@@ -94,8 +96,9 @@ The contract is validator-agnostic — plug in Zod (`schema.safeParse(entity)`),
|
|
|
94
96
|
directly — writing to the data collection can't be suppressed per-call, so for
|
|
95
97
|
each document actually affected, one row is written to an internal `outbox`
|
|
96
98
|
collection **in the same transaction** as the data write. A separate process,
|
|
97
|
-
`startOutboxRelay({ instanceId, ... })`, claims pending rows (atomic
|
|
98
|
-
`findOneAndUpdate
|
|
99
|
+
`startOutboxRelay({ instanceId, ... })`, claims pending rows (an atomic
|
|
100
|
+
`findOneAndUpdate` that pushes `nextAttemptAt` past a lease, safe across
|
|
101
|
+
multiple pods) and dispatches each to whatever's
|
|
99
102
|
registered via `.on(event, cb)` on that collection's service, retrying with
|
|
100
103
|
backoff and dead-lettering after `maxAttempts`. This means delivery is durable —
|
|
101
104
|
it survives a crash between the write committing and a listener running — and
|
|
@@ -108,10 +111,10 @@ const relay = startOutboxRelay({
|
|
|
108
111
|
instanceId: process.env.HOSTNAME ?? 'local', // required — identifies this lease holder
|
|
109
112
|
pollIntervalMs: 500, // idle poll interval when the outbox is empty
|
|
110
113
|
batchSize: 20, // rows claimed+dispatched per tick
|
|
111
|
-
leaseMs: 30_000, // how long a
|
|
114
|
+
leaseMs: 30_000, // how long a claimed row stays hidden from other claims
|
|
112
115
|
maxAttempts: 10, // attempts before a row is dead-lettered to 'failed'
|
|
113
116
|
keepDoneMs: 7 * 24 * 60 * 60 * 1000, // how long 'done' rows are kept; Infinity = forever
|
|
114
|
-
keepFailedMs:
|
|
117
|
+
keepFailedMs: 90 * 24 * 60 * 60 * 1000, // how long 'failed' rows are kept for inspection/replay; Infinity = forever
|
|
115
118
|
// backoffMs: (attempt) => ms, // override the default capped-exponential backoff
|
|
116
119
|
// autoStart: false, // don't start the poll loop — drive tick()/drain() manually (useful in tests)
|
|
117
120
|
});
|
|
@@ -145,6 +148,16 @@ Set `{ emitOutboxEvents: false }` on `createService` for infra collections with
|
|
|
145
148
|
no listeners (sequences, the outbox itself, etc.) — writes then take a leaner
|
|
146
149
|
fast path with no pre-reads and no transaction-wrapped outbox insert.
|
|
147
150
|
|
|
151
|
+
Set `{ toOutboxDoc: (doc) => ... }` on `createService` to decide what a
|
|
152
|
+
collection's outbox rows hold of a document, `doc` and `prevDoc` alike. Rows
|
|
153
|
+
reach every listener, and whatever those store (an audit log, a broker), so a
|
|
154
|
+
secret in a row leaks there. `({ secret, ...hook }) => hook` leaves it out (a
|
|
155
|
+
misspelled field is a type error), and an update that changes only that field
|
|
156
|
+
records no row. To have the change show without the value, return a hash of it
|
|
157
|
+
instead. The function must be pure and must not modify the document it gets. It
|
|
158
|
+
also gets stored documents that were never validated against `T` (older ones,
|
|
159
|
+
`skipValidation` writes), so handle a missing field: a throw fails the write.
|
|
160
|
+
|
|
148
161
|
If a listener needs request/actor context (e.g. an audit log), register
|
|
149
162
|
`setOutboxContextProvider(fn)` once at startup. It's called **synchronously at
|
|
150
163
|
write time**, in the same transaction as the data write, and the captured value
|
|
@@ -169,11 +182,10 @@ await getOutboxCollection().updateOne(
|
|
|
169
182
|
);
|
|
170
183
|
```
|
|
171
184
|
|
|
172
|
-
`
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
service.
|
|
185
|
+
`getRegisteredCollections()` lists the collections with a service in this
|
|
186
|
+
process (the ones the relay claims rows for). Reach for it only if you're
|
|
187
|
+
building custom tooling around the outbox; normal listener registration is just
|
|
188
|
+
`.on(...)` on the service.
|
|
177
189
|
|
|
178
190
|
### Multiple consumers
|
|
179
191
|
|
|
@@ -190,7 +202,10 @@ move is to bridge: run one relay whose only job is to republish each row to a
|
|
|
190
202
|
message broker (Kafka, SNS, SQS, etc.), and let downstream services subscribe
|
|
191
203
|
there. The outbox guarantees the write reaches the broker exactly once; the
|
|
192
204
|
broker handles fan-out from there. That bridging code lives in your app — this
|
|
193
|
-
package has no broker dependency by design.
|
|
205
|
+
package has no broker dependency by design. On the consuming side, hand each
|
|
206
|
+
received event to `dispatchEvent(collectionName, event)`: it calls the
|
|
207
|
+
listeners registered via `.on(...)` exactly like the relay does (awaits all of
|
|
208
|
+
them, rejects with the first failure so your consumer can retry).
|
|
194
209
|
|
|
195
210
|
Until a second independent consumer appears, don't add the broker. Bundle the
|
|
196
211
|
relay and all listeners in the same process.
|
|
@@ -198,41 +213,35 @@ relay and all listeners in the same process.
|
|
|
198
213
|
### Outbox row retention
|
|
199
214
|
|
|
200
215
|
When a row is marked `done` or `failed`, the relay sets its `expireAt` from
|
|
201
|
-
`keepDoneMs` (default **7 days**) or `keepFailedMs` (default
|
|
202
|
-
index deletes it after that. MongoDB's TTL
|
|
203
|
-
deletion isn't instant. Retention is stored per
|
|
204
|
-
index migration; it applies to rows settled from
|
|
205
|
-
|
|
206
|
-
By default `failed` rows are **never** auto-deleted. They accumulate until
|
|
207
|
-
manually requeued or removed, so a steady stream of permanently-failing events
|
|
208
|
-
will grow the outbox collection unbounded until someone looks at it. Set
|
|
209
|
-
`keepFailedMs` to cap that. When requeuing a failed row by hand, clear
|
|
216
|
+
`keepDoneMs` (default **7 days**) or `keepFailedMs` (default **90 days**), and a
|
|
217
|
+
TTL index deletes it after that; `Infinity` keeps rows forever. MongoDB's TTL
|
|
218
|
+
task runs every 60 seconds, so deletion isn't instant. Retention is stored per
|
|
219
|
+
row, so changing it needs no index migration; it applies to rows settled from
|
|
220
|
+
then on. A row no relay processes stays until one does. When requeuing a failed row by hand, clear
|
|
210
221
|
`expireAt` (as in the snippet above), or the TTL index may delete it while it
|
|
211
222
|
waits.
|
|
212
223
|
|
|
213
|
-
|
|
214
|
-
index on `processedOn` on startup.
|
|
215
|
-
|
|
216
|
-
## updateMany/removeMany always use the reliable per-document path
|
|
224
|
+
## How updateMany/removeMany work
|
|
217
225
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
document is
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
+
Both collect the matched `_id`s, then work through them in chunks of 500 inside a
|
|
227
|
+
single transaction: per chunk, one read of the current documents, one native
|
|
228
|
+
`updateMany`/`deleteMany` by `_id`, and (for `updateMany`) one read of the
|
|
229
|
+
results, plus one outbox insert. A failure in any chunk rolls back every chunk,
|
|
230
|
+
data **and** outbox rows. Every updated document is validated, and each outbox
|
|
231
|
+
row's `{ prevDoc, doc }` pair is exact: once the transaction has read a document,
|
|
232
|
+
any concurrent write to it makes the transaction fail with a transient write
|
|
233
|
+
conflict, and the whole call is retried.
|
|
226
234
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
limit (default 60s). Past that limit it throws — use `bulkWrite` for larger jobs
|
|
231
|
-
(no transaction/validation/event guarantees, but no cap either).
|
|
235
|
+
With `emitOutboxEvents: false`, `updateMany` still validates every document
|
|
236
|
+
(skipping the `prevDoc` reads and outbox rows); only without a validator, or
|
|
237
|
+
with `{ skipValidation: true }`, does it become one native `updateMany`.
|
|
232
238
|
|
|
233
|
-
|
|
234
|
-
`
|
|
235
|
-
|
|
239
|
+
There is **no size cap by default**. Pass `{ maxBatchSize }` (per-call, or as a
|
|
240
|
+
`createService` option) to fail fast: a single transaction processing a very
|
|
241
|
+
large matched set risks MongoDB's transaction lifetime limit (default 60s).
|
|
242
|
+
Past the cap it throws before writing anything; it never processes a partial
|
|
243
|
+
batch, so it can't be used to loop. For more than fits one transaction, split
|
|
244
|
+
the work into several calls (see the README for a rerunnable migration loop).
|
|
236
245
|
|
|
237
246
|
## Transactions
|
|
238
247
|
|
|
@@ -265,42 +274,23 @@ Exceptions: `bulkWrite`, and `removeOne`/`removeMany` on a service with
|
|
|
265
274
|
somewhere in the deployment.
|
|
266
275
|
- **Two MongoDB clients per `connect()` call** (primary + secondary-preferred reader).
|
|
267
276
|
`preferReadFromSecondary: true` only affects `find`/`findCursor`/`count`/`aggregate`.
|
|
268
|
-
- **`bulkWrite` requires `{ allowBulkwrite: true }`**
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
reliable path, since it reuses the exact same single-document logic
|
|
272
|
-
(`performSingleUpdate` in `db.ts`). To populate `{ prevDoc, doc }` on the outbox
|
|
273
|
-
row, the document is read with a separate `findOne` *before* running the atomic
|
|
274
|
-
`findOneAndUpdate` — a concurrent write landing in that gap means `prevDoc` may
|
|
275
|
-
not reflect the actual immediately-prior state. The atomic update itself is
|
|
276
|
-
unaffected (Mongo still applies it correctly); only the `prevDoc` value in the
|
|
277
|
-
outbox row can be stale. Treat `prevDoc` as best-effort, not authoritative, for
|
|
278
|
-
anything safety-critical.
|
|
279
|
-
- **`emitOutboxEvents: false` (no-outbox) writes only validate one sampled
|
|
280
|
-
document on `updateMany`, not every updated document.** For `$set`-with-literal-values
|
|
281
|
-
updates this is representative (every matched doc lands on the same value for
|
|
282
|
-
the touched fields), but for operators whose result depends on a document's
|
|
283
|
-
prior value (`$inc`, `$mul`, `$min`, `$max`, `$push`/`$addToSet`, `$rename`,
|
|
284
|
-
positional array filters), the same update can be valid for the sampled
|
|
285
|
-
document and invalid for another — undetected on that fast path. The default
|
|
286
|
-
reliable path (`emitOutboxEvents: true`, the default) validates every document
|
|
287
|
-
individually and doesn't have this gap.
|
|
277
|
+
- **`bulkWrite` requires `{ allowBulkwrite: true }`** and a service with
|
|
278
|
+
`emitOutboxEvents: false` — no schema validation runs on bulk writes, and no
|
|
279
|
+
outbox rows are written.
|
|
288
280
|
- **Nothing dispatches outbox events unless a relay is running.** Outbox rows
|
|
289
281
|
accumulate (durably, harmlessly) until some process calls `startOutboxRelay`.
|
|
290
282
|
Make sure at least one instance in your deployment runs it, or `.on(...)`
|
|
291
283
|
listeners will never fire.
|
|
292
|
-
- **`createService` called twice for the same collection name
|
|
293
|
-
call's
|
|
294
|
-
|
|
295
|
-
`EventEmitter` would silently replace the first's, orphaning any listeners
|
|
296
|
-
already registered on it. Reuse means `.on(...)`/`onPropertiesUpdated(...)`
|
|
284
|
+
- **`createService` called twice for the same collection name shares the first
|
|
285
|
+
call's listeners (and logs a warning).** Listeners are registered per
|
|
286
|
+
collection name, process-wide, so `.on(...)`/`onPropertiesUpdated(...)`
|
|
297
287
|
registered on *either* instance still receive dispatched events — but other
|
|
298
288
|
per-instance config is **not** shared, and a mismatch there is easy to miss
|
|
299
|
-
since the shared
|
|
289
|
+
since the shared listeners make everything else look consistent:
|
|
300
290
|
- **`emitOutboxEvents`** — if one instance has it `false`, writes through
|
|
301
|
-
that instance never enqueue an outbox row, so
|
|
302
|
-
silently never fire for those writes specifically (
|
|
303
|
-
|
|
291
|
+
that instance never enqueue an outbox row, so the shared listeners
|
|
292
|
+
silently never fire for those writes specifically (no event is generated
|
|
293
|
+
at all). This can be an intentional choice (e.g. a
|
|
304
294
|
backfill/migration instance skipping outbox writes on purpose), so it's
|
|
305
295
|
not something `createService` should reject — just be deliberate about it.
|
|
306
296
|
- **`validate`** — two instances can enforce different schemas on the same
|
|
@@ -310,10 +300,24 @@ Exceptions: `bulkWrite`, and `removeOne`/`removeMany` on a service with
|
|
|
310
300
|
- **`addCreatedOnField`/`addUpdatedOnField`** — documents written through
|
|
311
301
|
one instance may end up with `createdOn`/`updatedOn` and documents
|
|
312
302
|
written through the other may not, for the same collection.
|
|
303
|
+
- **`toOutboxDoc`** — a field one instance keeps out of the outbox reaches
|
|
304
|
+
the shared listeners through the other. Give every instance the same one.
|
|
313
305
|
Prefer creating each service once (e.g. in a shared module) and importing
|
|
314
306
|
that instance everywhere it's used, unless one of the above divergences is
|
|
315
|
-
actually what you want.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
307
|
+
actually what you want.
|
|
308
|
+
|
|
309
|
+
## Upgrading from 0.1
|
|
310
|
+
|
|
311
|
+
`CHANGELOG.md` (shipped with the package) lists what changed in 0.2.0. One step
|
|
312
|
+
has no place there: once no 0.1 relay runs any more, run this once per database.
|
|
313
|
+
It requeues rows a 0.1 relay left in status `processing` (a relay of this version
|
|
314
|
+
never claims them), and drops the indexes 0.1 created:
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
db.outbox.updateMany({ status: 'processing' }, [
|
|
318
|
+
{ $set: { status: 'pending', leasedBy: null, nextAttemptAt: { $ifNull: ['$leasedUntil', '$$NOW'] } } },
|
|
319
|
+
{ $unset: 'leasedUntil' },
|
|
320
|
+
]);
|
|
321
|
+
db.outbox.dropIndex('status_1_nextAttemptAt_1_leasedUntil_1');
|
|
322
|
+
db.outbox.dropIndex('processedOn_1'); // only if it still exists
|
|
323
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahrowe/mongo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Typed MongoDB CRUD service factory with built-in transactions, event hooks, and optional document validation.",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"types": "./dist/index.d.ts",
|
|
24
24
|
"files": [
|
|
25
25
|
"dist",
|
|
26
|
-
"docs/CLAUDE.md"
|
|
26
|
+
"docs/CLAUDE.md",
|
|
27
|
+
"CHANGELOG.md"
|
|
27
28
|
],
|
|
28
29
|
"sideEffects": false,
|
|
29
30
|
"exports": {
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
"mongodb": "^7.0.0"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
44
|
+
"@changesets/cli": "^3.0.3",
|
|
43
45
|
"@eslint/js": "^10.0.1",
|
|
44
46
|
"@faker-js/faker": "^10.1.0",
|
|
45
47
|
"@types/lodash": "^4.17.21",
|
|
@@ -56,6 +58,7 @@
|
|
|
56
58
|
"build": "tsc",
|
|
57
59
|
"lint": "eslint .",
|
|
58
60
|
"test": "vitest",
|
|
61
|
+
"changeset": "changeset",
|
|
59
62
|
"release": "bash scripts/release.sh"
|
|
60
63
|
}
|
|
61
64
|
}
|
package/dist/batchify.d.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
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
|
package/dist/batchify.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
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"}
|