@entifix/mongo 0.8.0 → 0.10.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/README.md +4 -4
- package/dist/adapters/filter-translator.d.ts +1 -1
- package/dist/adapters/filter-translator.d.ts.map +1 -1
- package/dist/adapters/filter-translator.js +6 -5
- package/dist/adapters/filter-translator.js.map +1 -1
- package/dist/adapters/make-mongo-facet-counter.d.ts +18 -0
- package/dist/adapters/make-mongo-facet-counter.d.ts.map +1 -0
- package/dist/adapters/make-mongo-facet-counter.js +76 -0
- package/dist/adapters/make-mongo-facet-counter.js.map +1 -0
- package/dist/adapters/make-mongo-link-resolver.d.ts.map +1 -1
- package/dist/adapters/make-mongo-link-resolver.js +2 -4
- package/dist/adapters/make-mongo-link-resolver.js.map +1 -1
- package/dist/adapters/make-mongo-repository.d.ts +5 -3
- package/dist/adapters/make-mongo-repository.d.ts.map +1 -1
- package/dist/adapters/make-mongo-repository.js +13 -20
- package/dist/adapters/make-mongo-repository.js.map +1 -1
- package/dist/adapters/mongo-facet-counter-factory.d.ts +18 -0
- package/dist/adapters/mongo-facet-counter-factory.d.ts.map +1 -0
- package/dist/adapters/mongo-facet-counter-factory.js +29 -0
- package/dist/adapters/mongo-facet-counter-factory.js.map +1 -0
- package/dist/adapters/mongo-repository-factory.d.ts.map +1 -1
- package/dist/adapters/mongo-repository-factory.js +2 -4
- package/dist/adapters/mongo-repository-factory.js.map +1 -1
- package/dist/errors/translate-mongo-error.d.ts +30 -0
- package/dist/errors/translate-mongo-error.d.ts.map +1 -0
- package/dist/errors/translate-mongo-error.js +74 -0
- package/dist/errors/translate-mongo-error.js.map +1 -0
- package/dist/inbox/store.d.ts +1 -2
- package/dist/inbox/store.d.ts.map +1 -1
- package/dist/inbox/store.js +3 -8
- package/dist/inbox/store.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mongo-database/mongo-database.d.ts +3 -3
- package/dist/mongo-database/mongo-database.d.ts.map +1 -1
- package/dist/mongo-database/mongo-database.js +2 -2
- package/dist/mongo-database/mongo-database.js.map +1 -1
- package/dist/mongo-database/mongo-tenant-directory.d.ts.map +1 -1
- package/dist/mongo-database/mongo-tenant-directory.js +2 -4
- package/dist/mongo-database/mongo-tenant-directory.js.map +1 -1
- package/dist/mongo-database/mongo-tenant-resolver.d.ts.map +1 -1
- package/dist/mongo-database/mongo-tenant-resolver.js +18 -7
- package/dist/mongo-database/mongo-tenant-resolver.js.map +1 -1
- package/dist/outbox/relay.d.ts +50 -9
- package/dist/outbox/relay.d.ts.map +1 -1
- package/dist/outbox/relay.js +67 -26
- package/dist/outbox/relay.js.map +1 -1
- package/dist/outbox/store.d.ts +3 -3
- package/dist/outbox/store.d.ts.map +1 -1
- package/dist/outbox/store.js +8 -22
- package/dist/outbox/store.js.map +1 -1
- package/dist/saga/store.d.ts +3 -4
- package/dist/saga/store.d.ts.map +1 -1
- package/dist/saga/store.js +10 -15
- package/dist/saga/store.js.map +1 -1
- package/dist/unit-of-work/command-inbox.d.ts +2 -3
- package/dist/unit-of-work/command-inbox.d.ts.map +1 -1
- package/dist/unit-of-work/command-inbox.js +3 -7
- package/dist/unit-of-work/command-inbox.js.map +1 -1
- package/dist/unit-of-work/mongo-unit-of-work.d.ts.map +1 -1
- package/dist/unit-of-work/mongo-unit-of-work.js +30 -26
- package/dist/unit-of-work/mongo-unit-of-work.js.map +1 -1
- package/package.json +8 -8
package/dist/outbox/relay.js
CHANGED
|
@@ -5,8 +5,17 @@ import { Context, Duration, Effect, Fiber, Result } from 'effect';
|
|
|
5
5
|
import { MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
6
6
|
import { recordOutboxStats } from './metrics.js';
|
|
7
7
|
import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
8
|
-
/** How often the sweep looks for entries the fast path did not carry. */
|
|
8
|
+
/** How often the sweep looks for entries the fast path did not carry. */ /**
|
|
9
|
+
* What an outbox entry records as the reason its publish failed: the failure's
|
|
10
|
+
* own message, or its code when it carries none — never empty, because an
|
|
11
|
+
* operator reading a quarantined entry has nothing else to go on.
|
|
12
|
+
*/ const failureText = (failure)=>failure.message === '' ? failure.code : failure.message;
|
|
13
|
+
export const SWEEP_INTERVAL = Duration.seconds(15);
|
|
9
14
|
/** Entries drained per pass, per database. */ export const BATCH = 100;
|
|
15
|
+
/**
|
|
16
|
+
* Hears nothing. The default only so a spec can start a relay without one —
|
|
17
|
+
* a host that passes no sink has a relay nobody can watch.
|
|
18
|
+
*/ export const ignoreRelayOutcome = ()=>Effect.void;
|
|
10
19
|
/**
|
|
11
20
|
* Publish attempts an outbox entry gets before it is quarantined, from
|
|
12
21
|
* config-service.
|
|
@@ -34,29 +43,44 @@ import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
|
34
43
|
* skip-so-the-head-moves: an entry that can never publish must stop blocking
|
|
35
44
|
* every entry behind it forever.
|
|
36
45
|
*/ export const drainOutbox = (outbox, bus, options)=>Effect.gen(function*() {
|
|
46
|
+
var _options_onOutcome;
|
|
37
47
|
const entries = yield* outbox.pending(BATCH);
|
|
38
48
|
let sent = 0;
|
|
49
|
+
const report = (_options_onOutcome = options.onOutcome) != null ? _options_onOutcome : ignoreRelayOutcome;
|
|
39
50
|
for (const entry of entries){
|
|
40
51
|
const outcome = yield* Effect.result(bus.publish(entry.event));
|
|
41
52
|
if (Result.isFailure(outcome)) {
|
|
53
|
+
var _options_onOutcome1;
|
|
42
54
|
const attempts = entry.attempts + 1;
|
|
43
55
|
const quarantine = attempts >= options.maxAttempts;
|
|
44
|
-
yield* outbox.recordFailure(entry, outcome.failure
|
|
56
|
+
yield* outbox.recordFailure(entry, failureText(outcome.failure), quarantine);
|
|
45
57
|
if (!quarantine) {
|
|
58
|
+
yield* report({
|
|
59
|
+
_tag: 'drained',
|
|
60
|
+
database: options.database,
|
|
61
|
+
sent
|
|
62
|
+
});
|
|
46
63
|
return sent;
|
|
47
64
|
}
|
|
48
|
-
yield*
|
|
65
|
+
yield* ((_options_onOutcome1 = options.onOutcome) != null ? _options_onOutcome1 : ignoreRelayOutcome)({
|
|
66
|
+
_tag: 'quarantined',
|
|
49
67
|
database: options.database,
|
|
50
68
|
eventId: entry.eventId,
|
|
51
69
|
eventName: entry.event.name,
|
|
52
70
|
attempts,
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
code: outcome.failure.code,
|
|
72
|
+
reason: failureText(outcome.failure)
|
|
73
|
+
});
|
|
55
74
|
continue;
|
|
56
75
|
}
|
|
57
76
|
yield* outbox.markSent(entry);
|
|
58
77
|
sent += 1;
|
|
59
78
|
}
|
|
79
|
+
yield* report({
|
|
80
|
+
_tag: 'drained',
|
|
81
|
+
database: options.database,
|
|
82
|
+
sent
|
|
83
|
+
});
|
|
60
84
|
return sent;
|
|
61
85
|
});
|
|
62
86
|
/**
|
|
@@ -70,18 +94,22 @@ import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
|
70
94
|
* Stats are sampled **after** the drain, so the gauge reports what is still
|
|
71
95
|
* waiting rather than what was waiting when the pass began.
|
|
72
96
|
*
|
|
73
|
-
* Every failure is caught and
|
|
74
|
-
* error that escapes ends the daemon silently — a
|
|
75
|
-
* indistinguishable from a relay with nothing to do.
|
|
97
|
+
* Every failure is caught and reported as `sweepFailed`. This runs inside
|
|
98
|
+
* `Effect.forever`, and an error that escapes ends the daemon silently — a
|
|
99
|
+
* relay that stopped is indistinguishable from a relay with nothing to do.
|
|
76
100
|
*/ export const sweepOutbox = (outbox, bus, options)=>Effect.gen(function*() {
|
|
77
101
|
var _options_onStats;
|
|
78
102
|
yield* drainOutbox(outbox, bus, options);
|
|
79
103
|
const sink = (_options_onStats = options.onStats) != null ? _options_onStats : recordOutboxStats;
|
|
80
104
|
yield* sink(options.database, (yield* outbox.stats()));
|
|
81
|
-
}).pipe(Effect.catch((
|
|
105
|
+
}).pipe(Effect.catch((failure)=>{
|
|
106
|
+
var _options_onOutcome;
|
|
107
|
+
return ((_options_onOutcome = options.onOutcome) != null ? _options_onOutcome : ignoreRelayOutcome)({
|
|
108
|
+
_tag: 'sweepFailed',
|
|
82
109
|
database: options.database,
|
|
83
|
-
|
|
84
|
-
})
|
|
110
|
+
failure
|
|
111
|
+
});
|
|
112
|
+
}));
|
|
85
113
|
/**
|
|
86
114
|
* Start the relay for a service that owns exactly one database.
|
|
87
115
|
*
|
|
@@ -106,6 +134,8 @@ import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
|
106
134
|
database: db.databaseName
|
|
107
135
|
}, (options == null ? void 0 : options.onStats) ? {
|
|
108
136
|
onStats: options.onStats
|
|
137
|
+
} : {}, (options == null ? void 0 : options.onOutcome) ? {
|
|
138
|
+
onOutcome: options.onOutcome
|
|
109
139
|
} : {})));
|
|
110
140
|
});
|
|
111
141
|
/**
|
|
@@ -135,7 +165,8 @@ import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
|
135
165
|
* pass: a sweep visits every tenant every interval, and the index build is the
|
|
136
166
|
* same request each time.
|
|
137
167
|
*/ export const sweepTenantOutboxes = (directory, bus, options)=>Effect.gen(function*() {
|
|
138
|
-
var _options_ensured;
|
|
168
|
+
var _options_onOutcome, _options_ensured;
|
|
169
|
+
const report = (_options_onOutcome = options.onOutcome) != null ? _options_onOutcome : ignoreRelayOutcome;
|
|
139
170
|
const ensured = (_options_ensured = options.ensured) != null ? _options_ensured : new Set();
|
|
140
171
|
const tenants = yield* directory.list();
|
|
141
172
|
for (const { handle: db } of tenants){
|
|
@@ -147,18 +178,24 @@ import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
|
147
178
|
}
|
|
148
179
|
yield* sweepOutbox(makeMongoOutbox(db), bus, _extends({
|
|
149
180
|
maxAttempts: options.maxAttempts,
|
|
150
|
-
database
|
|
181
|
+
database,
|
|
182
|
+
onOutcome: report
|
|
151
183
|
}, options.onStats ? {
|
|
152
184
|
onStats: options.onStats
|
|
153
185
|
} : {}));
|
|
154
|
-
}).pipe(Effect.catch((
|
|
186
|
+
}).pipe(Effect.catch((failure)=>report({
|
|
187
|
+
_tag: 'sweepFailed',
|
|
155
188
|
database,
|
|
156
|
-
|
|
157
|
-
})))
|
|
189
|
+
failure
|
|
190
|
+
})));
|
|
158
191
|
}
|
|
159
|
-
}).pipe(Effect.catch((
|
|
160
|
-
|
|
161
|
-
|
|
192
|
+
}).pipe(Effect.catch((failure)=>{
|
|
193
|
+
var _options_onOutcome;
|
|
194
|
+
return ((_options_onOutcome = options.onOutcome) != null ? _options_onOutcome : ignoreRelayOutcome)({
|
|
195
|
+
_tag: 'sweepFailed',
|
|
196
|
+
failure
|
|
197
|
+
});
|
|
198
|
+
}));
|
|
162
199
|
/**
|
|
163
200
|
* Start the relay for a store with one database per organization, listed by
|
|
164
201
|
* the {@link TenantDirectoryTag} the host provides.
|
|
@@ -176,6 +213,8 @@ import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
|
176
213
|
ensured
|
|
177
214
|
}, (options == null ? void 0 : options.onStats) ? {
|
|
178
215
|
onStats: options.onStats
|
|
216
|
+
} : {}, (options == null ? void 0 : options.onOutcome) ? {
|
|
217
|
+
onOutcome: options.onOutcome
|
|
179
218
|
} : {})));
|
|
180
219
|
});
|
|
181
220
|
/**
|
|
@@ -183,18 +222,20 @@ import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
|
183
222
|
*
|
|
184
223
|
* The fast path the sweep is the fallback for: an event drained here leaves as
|
|
185
224
|
* soon as its transaction commits, rather than up to {@link SWEEP_INTERVAL}
|
|
186
|
-
* later. Every failure is
|
|
187
|
-
* outbox, and the sweep will carry it.
|
|
188
|
-
*/ export const drainOutboxNow = (db)=>Effect.gen(function*() {
|
|
225
|
+
* later. Every failure is reported as `drainFailed` and swallowed — the entry
|
|
226
|
+
* is still in the outbox, and the sweep will carry it.
|
|
227
|
+
*/ export const drainOutboxNow = (db, onOutcome = ignoreRelayOutcome)=>Effect.gen(function*() {
|
|
189
228
|
const bus = yield* EventBusTag;
|
|
190
229
|
const maxAttempts = yield* OutboxMaxAttempts;
|
|
191
230
|
yield* drainOutbox(makeMongoOutbox(db), bus, {
|
|
192
231
|
maxAttempts,
|
|
193
|
-
database: db.databaseName
|
|
232
|
+
database: db.databaseName,
|
|
233
|
+
onOutcome
|
|
194
234
|
});
|
|
195
|
-
}).pipe(Effect.catch((
|
|
235
|
+
}).pipe(Effect.catch((failure)=>onOutcome({
|
|
236
|
+
_tag: 'drainFailed',
|
|
196
237
|
database: db.databaseName,
|
|
197
|
-
|
|
198
|
-
})))
|
|
238
|
+
failure
|
|
239
|
+
})));
|
|
199
240
|
|
|
200
241
|
//# sourceMappingURL=relay.js.map
|
package/dist/outbox/relay.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/outbox/relay.ts"],"sourcesContent":["import {\n ShutdownRegistryTag,\n type TenantDirectory,\n TenantDirectoryTag,\n} from '@entifix/business';\nimport {\n type EventBus,\n EventBusTag,\n type OutboxStats,\n type TransactionOutbox,\n} from '@entifix/transactions';\nimport { Context, Duration, Effect, Fiber, Result } from 'effect';\nimport type { Db } from 'mongodb';\n\nimport { MongoDatabaseTag } from '../mongo-database/mongo-database.js';\nimport { recordOutboxStats } from './metrics.js';\nimport { ensureOutboxIndexes, makeMongoOutbox } from './store.js';\n\n/** How often the sweep looks for entries the fast path did not carry. */\nexport const SWEEP_INTERVAL = Duration.seconds(15);\n/** Entries drained per pass, per database. */\nexport const BATCH = 100;\n\n/**\n * Publish attempts an outbox entry gets before it is quarantined, from\n * config-service.\n *\n * Configuration rather than a constant because it is a genuine operational\n * tunable: raising it while a flaky broker settles is a config edit, and the\n * next sweep reads the new value. That is the opposite of a subscription's\n * `maxAttempts`, which becomes an immutable queue argument and therefore stays\n * a literal beside its declaration.\n *\n * ⚠️ **Read it with `getNumber`, never a cast.** A value of `'five'` casts to\n * `NaN`, every `attempts >= maxAttempts` comparison goes false, and nothing is\n * ever quarantined — a relay that looks healthy while its head never moves.\n */\nexport class OutboxMaxAttempts extends Context.Service<\n OutboxMaxAttempts,\n number\n>()('OutboxMaxAttempts') {}\n\n/** What {@link drainOutbox} needs beyond the ports it publishes through. */\nexport interface DrainOptions {\n readonly maxAttempts: number;\n /** The database being drained; carried so a quarantine log names it. */\n readonly database: string;\n}\n\n/**\n * Publish one batch of pending entries, oldest first, and answer how many went.\n *\n * Two orderings are load-bearing here and neither is obvious:\n *\n * - A **still-deliverable** failure returns immediately rather than continuing.\n * The entries behind this one keep their order relative to it, which is what\n * stops a terminal event overtaking the `accepted` for the same transaction.\n * - A **quarantined** failure continues. That is the deliberate\n * skip-so-the-head-moves: an entry that can never publish must stop blocking\n * every entry behind it forever.\n */\nexport const drainOutbox = (\n outbox: TransactionOutbox,\n bus: EventBus,\n options: DrainOptions,\n) =>\n Effect.gen(function* () {\n const entries = yield* outbox.pending(BATCH);\n let sent = 0;\n for (const entry of entries) {\n const outcome = yield* Effect.result(bus.publish(entry.event));\n\n if (Result.isFailure(outcome)) {\n const attempts = entry.attempts + 1;\n const quarantine = attempts >= options.maxAttempts;\n\n yield* outbox.recordFailure(entry, outcome.failure.message, quarantine);\n\n if (!quarantine) {\n return sent;\n }\n\n yield* Effect.logError('outbox entry quarantined').pipe(\n Effect.annotateLogs({\n database: options.database,\n eventId: entry.eventId,\n eventName: entry.event.name,\n attempts,\n lastError: outcome.failure.message,\n }),\n );\n continue;\n }\n\n yield* outbox.markSent(entry);\n sent += 1;\n }\n return sent;\n });\n\n/**\n * Sample a sweep's depth and age.\n *\n * Defaults to {@link recordOutboxStats}, and a caller overrides it only to\n * assert what was sampled. There is no \"no sink\" option on purpose: an outbox\n * whose depth nothing reports is the failure the gauges exist to make visible.\n */\nexport type OutboxStatsSink = (\n database: string,\n stats: OutboxStats,\n) => Effect.Effect<void>;\n\n/**\n * Drain the one database this service owns, then sample its depth.\n *\n * The single-database counterpart to `startTenantOutboxRelay`. A\n * platform-plane store is one named database, so there is nothing to enumerate\n * and `listDatabases` — which needs a `MongoClient` and cluster-wide rights —\n * never comes into it.\n *\n * Stats are sampled **after** the drain, so the gauge reports what is still\n * waiting rather than what was waiting when the pass began.\n *\n * Every failure is caught and logged. This runs inside `Effect.forever`, and an\n * error that escapes ends the daemon silently — a relay that stopped is\n * indistinguishable from a relay with nothing to do.\n */\nexport const sweepOutbox = (\n outbox: TransactionOutbox,\n bus: EventBus,\n options: DrainOptions & { readonly onStats?: OutboxStatsSink },\n) =>\n Effect.gen(function* () {\n yield* drainOutbox(outbox, bus, options);\n const sink = options.onStats ?? recordOutboxStats;\n yield* sink(options.database, yield* outbox.stats());\n }).pipe(\n Effect.catch(error =>\n Effect.logError('outbox sweep failed').pipe(\n Effect.annotateLogs({\n database: options.database,\n error: String(error),\n }),\n ),\n ),\n );\n\n/**\n * Start the relay for a service that owns exactly one database.\n *\n * ⚠️ **The shutdown hook is `flush`, not `stop-intake`, and the phase is the\n * whole point.** `AmqpEventBusLayer` registers `stop-intake` to cancel consumers\n * and drain deliveries; this runs after that, so the last sweep publishes into a\n * connection that is still open. Registered the other way round it would try to\n * publish through a channel already closing, and the entries it was holding\n * would sit until the next boot.\n *\n * The daemon is `delay |> forever`, so the first sweep is one interval away\n * rather than at boot — the write path drains inline, and a sweep racing the\n * index bootstrap on a cold database only produces noise.\n */\nexport const startOutboxRelay = (options?: {\n readonly onStats?: OutboxStatsSink;\n}) =>\n Effect.gen(function* () {\n const db = yield* MongoDatabaseTag;\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n\n yield* ensureOutboxIndexes(db);\n const outbox = makeMongoOutbox(db);\n\n yield* runRelayDaemon(\n sweepOutbox(outbox, bus, {\n maxAttempts,\n database: db.databaseName,\n ...(options?.onStats ? { onStats: options.onStats } : {}),\n }),\n );\n });\n\n/**\n * Runs `sweepOnce` every {@link SWEEP_INTERVAL}, and once more on the way out.\n *\n * Interrupting the daemon before that last pass is what stops two sweeps\n * publishing the same entry; running the pass is what the hook is for — an\n * event committed a moment before shutdown would otherwise wait out the next\n * process's whole interval.\n */\nconst runRelayDaemon = (sweepOnce: Effect.Effect<void>) =>\n Effect.gen(function* () {\n const shutdown = yield* ShutdownRegistryTag;\n\n const daemon = yield* Effect.forkDetach(\n sweepOnce.pipe(Effect.delay(SWEEP_INTERVAL), Effect.forever),\n );\n\n yield* shutdown.register({\n name: 'outbox-relay',\n phase: 'flush',\n run: Fiber.interrupt(daemon).pipe(Effect.andThen(sweepOnce)),\n });\n });\n\n/**\n * One pass over every organization's outbox in a per-organization store.\n *\n * ⚠️ **Each tenant is caught on its own.** A failure that recurs — an index\n * conflict on one old database, say — would otherwise abandon every tenant\n * listed after it on every pass, while the relay went on looking healthy.\n *\n * Indexes are ensured once per database per process (`ensured`), not on every\n * pass: a sweep visits every tenant every interval, and the index build is the\n * same request each time.\n */\nexport const sweepTenantOutboxes = (\n directory: TenantDirectory<Db>,\n bus: EventBus,\n options: {\n readonly maxAttempts: number;\n readonly onStats?: OutboxStatsSink;\n readonly ensured?: Set<string>;\n },\n) =>\n Effect.gen(function* () {\n const ensured = options.ensured ?? new Set<string>();\n const tenants = yield* directory.list();\n\n for (const { handle: db } of tenants) {\n const database = db.databaseName;\n yield* Effect.gen(function* () {\n if (!ensured.has(database)) {\n yield* ensureOutboxIndexes(db);\n ensured.add(database);\n }\n yield* sweepOutbox(makeMongoOutbox(db), bus, {\n maxAttempts: options.maxAttempts,\n database,\n ...(options.onStats ? { onStats: options.onStats } : {}),\n });\n }).pipe(\n Effect.catch(error =>\n Effect.logError('draining a tenant outbox failed').pipe(\n Effect.annotateLogs({ database, error: String(error) }),\n ),\n ),\n );\n }\n }).pipe(\n Effect.catch(error =>\n Effect.logError('tenant outbox sweep failed').pipe(\n Effect.annotateLogs({ error: String(error) }),\n ),\n ),\n );\n\n/**\n * Start the relay for a store with one database per organization, listed by\n * the {@link TenantDirectoryTag} the host provides.\n *\n * The same two speeds as {@link startOutboxRelay}: the write path drains its\n * own tenant inline with {@link drainOutboxNow}, and this sweep carries\n * whatever that missed.\n */\nexport const startTenantOutboxRelay = (options?: {\n readonly onStats?: OutboxStatsSink;\n}) =>\n Effect.gen(function* () {\n const directory = (yield* TenantDirectoryTag) as TenantDirectory<Db>;\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n const ensured = new Set<string>();\n\n yield* runRelayDaemon(\n sweepTenantOutboxes(directory, bus, {\n maxAttempts,\n ensured,\n ...(options?.onStats ? { onStats: options.onStats } : {}),\n }),\n );\n });\n\n/**\n * Drain one database's outbox now, for a write path that has just committed.\n *\n * The fast path the sweep is the fallback for: an event drained here leaves as\n * soon as its transaction commits, rather than up to {@link SWEEP_INTERVAL}\n * later. Every failure is logged and swallowed — the entry is still in the\n * outbox, and the sweep will carry it.\n */\nexport const drainOutboxNow = (db: Db) =>\n Effect.gen(function* () {\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n yield* drainOutbox(makeMongoOutbox(db), bus, {\n maxAttempts,\n database: db.databaseName,\n });\n }).pipe(\n Effect.catch(error =>\n Effect.logWarning('inline outbox drain failed').pipe(\n Effect.annotateLogs({\n database: db.databaseName,\n error: String(error),\n }),\n ),\n ),\n );\n"],"names":["ShutdownRegistryTag","TenantDirectoryTag","EventBusTag","Context","Duration","Effect","Fiber","Result","MongoDatabaseTag","recordOutboxStats","ensureOutboxIndexes","makeMongoOutbox","SWEEP_INTERVAL","seconds","BATCH","OutboxMaxAttempts","Service","drainOutbox","outbox","bus","options","gen","entries","pending","sent","entry","outcome","result","publish","event","isFailure","attempts","quarantine","maxAttempts","recordFailure","failure","message","logError","pipe","annotateLogs","database","eventId","eventName","name","lastError","markSent","sweepOutbox","sink","onStats","stats","catch","error","String","startOutboxRelay","db","runRelayDaemon","databaseName","sweepOnce","shutdown","daemon","forkDetach","delay","forever","register","phase","run","interrupt","andThen","sweepTenantOutboxes","directory","ensured","Set","tenants","list","handle","has","add","startTenantOutboxRelay","drainOutboxNow","logWarning"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/outbox/relay.ts"],"sourcesContent":["import {\n ShutdownRegistryTag,\n type TenantDirectory,\n TenantDirectoryTag,\n} from '@entifix/business';\nimport {\n type EventBus,\n EventBusTag,\n type OutboxStats,\n type TransactionOutbox,\n} from '@entifix/transactions';\nimport { Context, Duration, Effect, Fiber, Result } from 'effect';\nimport type { Db } from 'mongodb';\n\nimport { MongoDatabaseTag } from '../mongo-database/mongo-database.js';\nimport { recordOutboxStats } from './metrics.js';\nimport { ensureOutboxIndexes, makeMongoOutbox } from './store.js';\n\n/** How often the sweep looks for entries the fast path did not carry. */\n/**\n * What an outbox entry records as the reason its publish failed: the failure's\n * own message, or its code when it carries none — never empty, because an\n * operator reading a quarantined entry has nothing else to go on.\n */\nconst failureText = (failure: {\n readonly message: string;\n readonly code: string;\n}) => (failure.message === '' ? failure.code : failure.message);\n\nexport const SWEEP_INTERVAL = Duration.seconds(15);\n/** Entries drained per pass, per database. */\nexport const BATCH = 100;\n\n/**\n * What a relay pass did, for the host to log\n * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).\n *\n * The relay decides nothing about level or sink: it says what happened and the\n * fork site that started it — the slice's shell — decides how loud that is. A\n * `failure` is whatever the store or the bus failed with; a typed one carries\n * its code and category for the log line.\n */\nexport type RelayOutcome =\n | {\n readonly _tag: 'drained';\n readonly database: string;\n readonly sent: number;\n }\n | {\n readonly _tag: 'quarantined';\n readonly database: string;\n readonly eventId: string;\n readonly eventName: string;\n readonly attempts: number;\n readonly code: string;\n readonly reason: string;\n }\n | {\n readonly _tag: 'sweepFailed';\n readonly database?: string;\n readonly failure: unknown;\n }\n | {\n readonly _tag: 'drainFailed';\n readonly database: string;\n readonly failure: unknown;\n };\n\n/** Where a relay's outcomes go. The host's fork site supplies it. */\nexport type RelayOutcomeSink = (outcome: RelayOutcome) => Effect.Effect<void>;\n\n/**\n * Hears nothing. The default only so a spec can start a relay without one —\n * a host that passes no sink has a relay nobody can watch.\n */\nexport const ignoreRelayOutcome: RelayOutcomeSink = () => Effect.void;\n\n/**\n * Publish attempts an outbox entry gets before it is quarantined, from\n * config-service.\n *\n * Configuration rather than a constant because it is a genuine operational\n * tunable: raising it while a flaky broker settles is a config edit, and the\n * next sweep reads the new value. That is the opposite of a subscription's\n * `maxAttempts`, which becomes an immutable queue argument and therefore stays\n * a literal beside its declaration.\n *\n * ⚠️ **Read it with `getNumber`, never a cast.** A value of `'five'` casts to\n * `NaN`, every `attempts >= maxAttempts` comparison goes false, and nothing is\n * ever quarantined — a relay that looks healthy while its head never moves.\n */\nexport class OutboxMaxAttempts extends Context.Service<\n OutboxMaxAttempts,\n number\n>()('OutboxMaxAttempts') {}\n\n/** What {@link drainOutbox} needs beyond the ports it publishes through. */\nexport interface DrainOptions {\n readonly maxAttempts: number;\n /** The database being drained; carried so a quarantine log names it. */\n readonly database: string;\n /** Where the pass reports what it did; see {@link RelayOutcome}. */\n readonly onOutcome?: RelayOutcomeSink;\n}\n\n/**\n * Publish one batch of pending entries, oldest first, and answer how many went.\n *\n * Two orderings are load-bearing here and neither is obvious:\n *\n * - A **still-deliverable** failure returns immediately rather than continuing.\n * The entries behind this one keep their order relative to it, which is what\n * stops a terminal event overtaking the `accepted` for the same transaction.\n * - A **quarantined** failure continues. That is the deliberate\n * skip-so-the-head-moves: an entry that can never publish must stop blocking\n * every entry behind it forever.\n */\nexport const drainOutbox = (\n outbox: TransactionOutbox,\n bus: EventBus,\n options: DrainOptions,\n) =>\n Effect.gen(function* () {\n const entries = yield* outbox.pending(BATCH);\n let sent = 0;\n const report = options.onOutcome ?? ignoreRelayOutcome;\n for (const entry of entries) {\n const outcome = yield* Effect.result(bus.publish(entry.event));\n\n if (Result.isFailure(outcome)) {\n const attempts = entry.attempts + 1;\n const quarantine = attempts >= options.maxAttempts;\n\n yield* outbox.recordFailure(\n entry,\n failureText(outcome.failure),\n quarantine,\n );\n\n if (!quarantine) {\n yield* report({ _tag: 'drained', database: options.database, sent });\n return sent;\n }\n\n yield* (options.onOutcome ?? ignoreRelayOutcome)({\n _tag: 'quarantined',\n database: options.database,\n eventId: entry.eventId,\n eventName: entry.event.name,\n attempts,\n code: outcome.failure.code,\n reason: failureText(outcome.failure),\n });\n continue;\n }\n\n yield* outbox.markSent(entry);\n sent += 1;\n }\n yield* report({ _tag: 'drained', database: options.database, sent });\n return sent;\n });\n\n/**\n * Sample a sweep's depth and age.\n *\n * Defaults to {@link recordOutboxStats}, and a caller overrides it only to\n * assert what was sampled. There is no \"no sink\" option on purpose: an outbox\n * whose depth nothing reports is the failure the gauges exist to make visible.\n */\nexport type OutboxStatsSink = (\n database: string,\n stats: OutboxStats,\n) => Effect.Effect<void>;\n\n/**\n * Drain the one database this service owns, then sample its depth.\n *\n * The single-database counterpart to `startTenantOutboxRelay`. A\n * platform-plane store is one named database, so there is nothing to enumerate\n * and `listDatabases` — which needs a `MongoClient` and cluster-wide rights —\n * never comes into it.\n *\n * Stats are sampled **after** the drain, so the gauge reports what is still\n * waiting rather than what was waiting when the pass began.\n *\n * Every failure is caught and reported as `sweepFailed`. This runs inside\n * `Effect.forever`, and an error that escapes ends the daemon silently — a\n * relay that stopped is indistinguishable from a relay with nothing to do.\n */\nexport const sweepOutbox = (\n outbox: TransactionOutbox,\n bus: EventBus,\n options: DrainOptions & { readonly onStats?: OutboxStatsSink },\n) =>\n Effect.gen(function* () {\n yield* drainOutbox(outbox, bus, options);\n const sink = options.onStats ?? recordOutboxStats;\n yield* sink(options.database, yield* outbox.stats());\n }).pipe(\n Effect.catch(failure =>\n (options.onOutcome ?? ignoreRelayOutcome)({\n _tag: 'sweepFailed',\n database: options.database,\n failure,\n }),\n ),\n );\n\n/**\n * Start the relay for a service that owns exactly one database.\n *\n * ⚠️ **The shutdown hook is `flush`, not `stop-intake`, and the phase is the\n * whole point.** `AmqpEventBusLayer` registers `stop-intake` to cancel consumers\n * and drain deliveries; this runs after that, so the last sweep publishes into a\n * connection that is still open. Registered the other way round it would try to\n * publish through a channel already closing, and the entries it was holding\n * would sit until the next boot.\n *\n * The daemon is `delay |> forever`, so the first sweep is one interval away\n * rather than at boot — the write path drains inline, and a sweep racing the\n * index bootstrap on a cold database only produces noise.\n */\nexport const startOutboxRelay = (options?: {\n readonly onStats?: OutboxStatsSink;\n readonly onOutcome?: RelayOutcomeSink;\n}) =>\n Effect.gen(function* () {\n const db = yield* MongoDatabaseTag;\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n\n yield* ensureOutboxIndexes(db);\n const outbox = makeMongoOutbox(db);\n\n yield* runRelayDaemon(\n sweepOutbox(outbox, bus, {\n maxAttempts,\n database: db.databaseName,\n ...(options?.onStats ? { onStats: options.onStats } : {}),\n ...(options?.onOutcome ? { onOutcome: options.onOutcome } : {}),\n }),\n );\n });\n\n/**\n * Runs `sweepOnce` every {@link SWEEP_INTERVAL}, and once more on the way out.\n *\n * Interrupting the daemon before that last pass is what stops two sweeps\n * publishing the same entry; running the pass is what the hook is for — an\n * event committed a moment before shutdown would otherwise wait out the next\n * process's whole interval.\n */\nconst runRelayDaemon = (sweepOnce: Effect.Effect<void>) =>\n Effect.gen(function* () {\n const shutdown = yield* ShutdownRegistryTag;\n\n const daemon = yield* Effect.forkDetach(\n sweepOnce.pipe(Effect.delay(SWEEP_INTERVAL), Effect.forever),\n );\n\n yield* shutdown.register({\n name: 'outbox-relay',\n phase: 'flush',\n run: Fiber.interrupt(daemon).pipe(Effect.andThen(sweepOnce)),\n });\n });\n\n/**\n * One pass over every organization's outbox in a per-organization store.\n *\n * ⚠️ **Each tenant is caught on its own.** A failure that recurs — an index\n * conflict on one old database, say — would otherwise abandon every tenant\n * listed after it on every pass, while the relay went on looking healthy.\n *\n * Indexes are ensured once per database per process (`ensured`), not on every\n * pass: a sweep visits every tenant every interval, and the index build is the\n * same request each time.\n */\nexport const sweepTenantOutboxes = (\n directory: TenantDirectory<Db>,\n bus: EventBus,\n options: {\n readonly maxAttempts: number;\n readonly onStats?: OutboxStatsSink;\n readonly onOutcome?: RelayOutcomeSink;\n readonly ensured?: Set<string>;\n },\n) =>\n Effect.gen(function* () {\n const report = options.onOutcome ?? ignoreRelayOutcome;\n const ensured = options.ensured ?? new Set<string>();\n const tenants = yield* directory.list();\n\n for (const { handle: db } of tenants) {\n const database = db.databaseName;\n yield* Effect.gen(function* () {\n if (!ensured.has(database)) {\n yield* ensureOutboxIndexes(db);\n ensured.add(database);\n }\n yield* sweepOutbox(makeMongoOutbox(db), bus, {\n maxAttempts: options.maxAttempts,\n database,\n onOutcome: report,\n ...(options.onStats ? { onStats: options.onStats } : {}),\n });\n }).pipe(\n Effect.catch(failure =>\n report({ _tag: 'sweepFailed', database, failure }),\n ),\n );\n }\n }).pipe(\n Effect.catch(failure =>\n (options.onOutcome ?? ignoreRelayOutcome)({\n _tag: 'sweepFailed',\n failure,\n }),\n ),\n );\n\n/**\n * Start the relay for a store with one database per organization, listed by\n * the {@link TenantDirectoryTag} the host provides.\n *\n * The same two speeds as {@link startOutboxRelay}: the write path drains its\n * own tenant inline with {@link drainOutboxNow}, and this sweep carries\n * whatever that missed.\n */\nexport const startTenantOutboxRelay = (options?: {\n readonly onStats?: OutboxStatsSink;\n readonly onOutcome?: RelayOutcomeSink;\n}) =>\n Effect.gen(function* () {\n const directory = (yield* TenantDirectoryTag) as TenantDirectory<Db>;\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n const ensured = new Set<string>();\n\n yield* runRelayDaemon(\n sweepTenantOutboxes(directory, bus, {\n maxAttempts,\n ensured,\n ...(options?.onStats ? { onStats: options.onStats } : {}),\n ...(options?.onOutcome ? { onOutcome: options.onOutcome } : {}),\n }),\n );\n });\n\n/**\n * Drain one database's outbox now, for a write path that has just committed.\n *\n * The fast path the sweep is the fallback for: an event drained here leaves as\n * soon as its transaction commits, rather than up to {@link SWEEP_INTERVAL}\n * later. Every failure is reported as `drainFailed` and swallowed — the entry\n * is still in the outbox, and the sweep will carry it.\n */\nexport const drainOutboxNow = (\n db: Db,\n onOutcome: RelayOutcomeSink = ignoreRelayOutcome,\n) =>\n Effect.gen(function* () {\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n yield* drainOutbox(makeMongoOutbox(db), bus, {\n maxAttempts,\n database: db.databaseName,\n onOutcome,\n });\n }).pipe(\n Effect.catch(failure =>\n onOutcome({ _tag: 'drainFailed', database: db.databaseName, failure }),\n ),\n );\n"],"names":["ShutdownRegistryTag","TenantDirectoryTag","EventBusTag","Context","Duration","Effect","Fiber","Result","MongoDatabaseTag","recordOutboxStats","ensureOutboxIndexes","makeMongoOutbox","failureText","failure","message","code","SWEEP_INTERVAL","seconds","BATCH","ignoreRelayOutcome","void","OutboxMaxAttempts","Service","drainOutbox","outbox","bus","options","gen","entries","pending","sent","report","onOutcome","entry","outcome","result","publish","event","isFailure","attempts","quarantine","maxAttempts","recordFailure","_tag","database","eventId","eventName","name","reason","markSent","sweepOutbox","sink","onStats","stats","pipe","catch","startOutboxRelay","db","runRelayDaemon","databaseName","sweepOnce","shutdown","daemon","forkDetach","delay","forever","register","phase","run","interrupt","andThen","sweepTenantOutboxes","directory","ensured","Set","tenants","list","handle","has","add","startTenantOutboxRelay","drainOutboxNow"],"mappings":"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"}
|
package/dist/outbox/store.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { DomainEvent } from '@entifix/core';
|
|
2
2
|
import type { OutboxEntry, TransactionOutbox } from '@entifix/transactions';
|
|
3
3
|
import { Effect } from 'effect';
|
|
4
4
|
import type { Db } from 'mongodb';
|
|
@@ -50,7 +50,7 @@ export declare const outboxDocument: (event: DomainEvent) => OutboxEntry;
|
|
|
50
50
|
*
|
|
51
51
|
* Answers whether an entry was actually revived.
|
|
52
52
|
*/
|
|
53
|
-
export declare const reviveQuarantined: (db: Db, eventId: string) => Effect.Effect<boolean,
|
|
53
|
+
export declare const reviveQuarantined: (db: Db, eventId: string) => Effect.Effect<boolean, import("@entifix/core").StoreFailure, never>;
|
|
54
54
|
/**
|
|
55
55
|
* Ensures the two indexes the outbox depends on.
|
|
56
56
|
*
|
|
@@ -70,7 +70,7 @@ export declare const reviveQuarantined: (db: Db, eventId: string) => Effect.Effe
|
|
|
70
70
|
* logs what it catches. The fix is `pnpm run <app>:dev:reset`; nothing runs in
|
|
71
71
|
* production, so there is no migration to write.
|
|
72
72
|
*/
|
|
73
|
-
export declare const ensureOutboxIndexes: (db: Db) => Effect.Effect<void,
|
|
73
|
+
export declare const ensureOutboxIndexes: (db: Db) => Effect.Effect<void, import("@entifix/core").StoreFailure, never>;
|
|
74
74
|
/**
|
|
75
75
|
* Mongo-backed {@link TransactionOutbox} over one database.
|
|
76
76
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/outbox/store.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/outbox/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC5E,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAIlC;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;AAatD;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,OAAO,WAAW,KAAG,WAWlD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,iBAAiB,GAAI,IAAI,EAAE,EAAE,SAAS,MAAM,wEAiBrD,CAAC;AAEL;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,mBAAmB,GAAI,IAAI,EAAE,qEAWtC,CAAC;AAEL;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAAI,IAAI,EAAE,KAAG,iBA4FxC,CAAC"}
|
package/dist/outbox/store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
-
import { EntifixConnError } from '@entifix/core';
|
|
3
2
|
import { Effect } from 'effect';
|
|
3
|
+
import { translateMongoError } from '../errors/translate-mongo-error.js';
|
|
4
4
|
/**
|
|
5
5
|
* The outbox collection, and it lives in the **same database as the entity it
|
|
6
6
|
* describes** — never in a shared control-plane store.
|
|
@@ -83,10 +83,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
83
83
|
});
|
|
84
84
|
return result.modifiedCount === 1;
|
|
85
85
|
},
|
|
86
|
-
catch: (error)=>
|
|
87
|
-
eventId,
|
|
88
|
-
db: db.databaseName
|
|
89
|
-
})
|
|
86
|
+
catch: (error)=>translateMongoError(error)
|
|
90
87
|
});
|
|
91
88
|
/**
|
|
92
89
|
* Ensures the two indexes the outbox depends on.
|
|
@@ -123,9 +120,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
123
120
|
}
|
|
124
121
|
});
|
|
125
122
|
},
|
|
126
|
-
catch: (error)=>
|
|
127
|
-
db: db.databaseName
|
|
128
|
-
})
|
|
123
|
+
catch: (error)=>translateMongoError(error)
|
|
129
124
|
});
|
|
130
125
|
/**
|
|
131
126
|
* Mongo-backed {@link TransactionOutbox} over one database.
|
|
@@ -136,7 +131,6 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
136
131
|
* tested without a broker.
|
|
137
132
|
*/ export const makeMongoOutbox = (db)=>{
|
|
138
133
|
const collection = db.collection(OUTBOX_COLLECTION);
|
|
139
|
-
const fail = (message, error, details)=>new EntifixConnError(message, error, details);
|
|
140
134
|
return {
|
|
141
135
|
enqueue: (event)=>Effect.tryPromise({
|
|
142
136
|
try: async ()=>{
|
|
@@ -152,9 +146,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
152
146
|
throw error;
|
|
153
147
|
}
|
|
154
148
|
},
|
|
155
|
-
catch: (error)=>
|
|
156
|
-
eventId: event.id
|
|
157
|
-
})
|
|
149
|
+
catch: (error)=>translateMongoError(error)
|
|
158
150
|
}),
|
|
159
151
|
pending: (limit)=>Effect.tryPromise({
|
|
160
152
|
// Oldest first, so `accepted` reaches the bus before the terminal event
|
|
@@ -165,9 +157,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
165
157
|
}, WITHOUT_MONGO_ID).sort({
|
|
166
158
|
createdAt: 1
|
|
167
159
|
}).limit(limit).toArray(),
|
|
168
|
-
catch: (error)=>
|
|
169
|
-
db: db.databaseName
|
|
170
|
-
})
|
|
160
|
+
catch: (error)=>translateMongoError(error)
|
|
171
161
|
}),
|
|
172
162
|
markSent: (entry)=>Effect.tryPromise({
|
|
173
163
|
try: ()=>collection.updateOne({
|
|
@@ -177,9 +167,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
177
167
|
sent: true
|
|
178
168
|
}
|
|
179
169
|
}),
|
|
180
|
-
catch: (error)=>
|
|
181
|
-
eventId: entry.eventId
|
|
182
|
-
})
|
|
170
|
+
catch: (error)=>translateMongoError(error)
|
|
183
171
|
}).pipe(Effect.asVoid),
|
|
184
172
|
stats: ()=>Effect.tryPromise({
|
|
185
173
|
// Two counts and one `findOne`, rather than reading the entries: the
|
|
@@ -213,7 +201,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
213
201
|
oldestPendingAt: oldest.createdAt
|
|
214
202
|
});
|
|
215
203
|
},
|
|
216
|
-
catch: (error)=>
|
|
204
|
+
catch: (error)=>translateMongoError(error)
|
|
217
205
|
}),
|
|
218
206
|
recordFailure: (entry, error, quarantine)=>Effect.tryPromise({
|
|
219
207
|
// `$inc` rather than a read-modify-write: two relays can be draining the
|
|
@@ -231,9 +219,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
231
219
|
quarantined: quarantine
|
|
232
220
|
}
|
|
233
221
|
}),
|
|
234
|
-
catch: (cause)=>
|
|
235
|
-
eventId: entry.eventId
|
|
236
|
-
})
|
|
222
|
+
catch: (cause)=>translateMongoError(cause)
|
|
237
223
|
}).pipe(Effect.asVoid)
|
|
238
224
|
};
|
|
239
225
|
};
|
package/dist/outbox/store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/outbox/store.ts"],"sourcesContent":["import { type DomainEvent, EntifixConnError } from '@entifix/core';\nimport type { OutboxEntry, TransactionOutbox } from '@entifix/transactions';\nimport { Effect } from 'effect';\nimport type { Db } from 'mongodb';\n\n/**\n * The outbox collection, and it lives in the **same database as the entity it\n * describes** — never in a shared control-plane store.\n *\n * Two reasons, and the second only shows up later. Same database means the\n * entity write and its event are one single-database transaction, which stays a\n * single-*shard* transaction if this ever shards; a control-plane outbox would\n * be cross-database from the first commit. And an outbox holds event payloads:\n * a `TransactionEvent` carries no tenant data, but `catalog.published` carries a\n * whole offering and `order.placed` a whole receipt, and neither may land in the\n * control plane on its way to the bus.\n *\n * That rule is why this lives here rather than in one service. It applies\n * identically to a per-organization `tenant_<id>` and to a single platform-plane\n * database, and the second publisher is what turned a copy into a seam.\n */\nexport const OUTBOX_COLLECTION = 'transaction_outbox';\n\n/** Drop Mongo's internal `_id` from every read. */\nconst WITHOUT_MONGO_ID = { projection: { _id: 0 } } as const;\n\n/** Mongo's duplicate-key error, which is how a replayed command is recognised. */\nconst DUPLICATE_KEY = 11000;\n\nconst isDuplicateKey = (error: unknown): boolean =>\n typeof error === 'object' &&\n error !== null &&\n (error as { code?: unknown }).code === DUPLICATE_KEY;\n\n/**\n * The stored shape of an outbox entry.\n *\n * Exported because the `completed` entry is **not** written through the port:\n * it has to join the entity write's session to be atomic with it, and a session\n * cannot live in a framework-free contract. The handler inserts this document\n * itself; everything else goes through {@link makeMongoOutbox}.\n */\nexport const outboxDocument = (event: DomainEvent): OutboxEntry => ({\n eventId: event.id,\n event,\n sent: false,\n // Defaulted here rather than at each write site because there are two of\n // them: the relay's `enqueue`, and the transaction handler's own insert\n // inside the entity's Mongo session. An entry missing `attempts` would sort\n // into `pending` and then fail `attempts + 1` arithmetic on a `undefined`.\n attempts: 0,\n quarantined: false,\n createdAt: event.at,\n});\n\n/**\n * Puts a **quarantined** entry back in the queue, and touches nothing else.\n *\n * A rebuild walk's repair for the one failure measured on a live lab: with the\n * broker down long enough, the relay spends `outbox.maxAttempts` on an\n * announcement and quarantines it (the deliberate skip-so-the-head-moves),\n * after which the tenant store reads one thing and the storefront shows another\n * with nothing reporting it. Re-emitting cannot help on its own — the event id is\n * `<offeringId>:<publishedAt>` and the unique index answers `duplicate`.\n *\n * ⚠️ **`quarantined: true` is in the filter, not the update.** An entry that is\n * merely pending keeps its `attempts`, and one already `sent` is never re-sent —\n * which is what keeps this from quietly becoming \"re-announce everything\", a\n * walk that would re-publish the whole fleet on every boot and un-quarantine\n * genuine poison each time.\n *\n * Not a `TransactionOutbox` method: the port is framework-free and shared by\n * every publisher, and this is a repair specific to the rebuild. `outboxDocument`\n * is exported beside it for the same class of reason.\n *\n * Answers whether an entry was actually revived.\n */\nexport const reviveQuarantined = (db: Db, eventId: string) =>\n Effect.tryPromise({\n try: async () => {\n const result = await db\n .collection<OutboxEntry>(OUTBOX_COLLECTION)\n .updateOne(\n { eventId, quarantined: true },\n {\n $set: { sent: false, quarantined: false, attempts: 0 },\n // The failure that put it here is spent; leaving it would make the\n // next quarantine's log name an error from a different outage.\n $unset: { lastError: '' },\n },\n );\n return result.modifiedCount === 1;\n },\n catch: error =>\n new EntifixConnError(\n 'Failed to revive a quarantined outbox entry',\n error,\n {\n eventId,\n db: db.databaseName,\n },\n ),\n });\n\n/**\n * Ensures the two indexes the outbox depends on.\n *\n * The unique one is load-bearing rather than defensive: it *is* the idempotency\n * check behind the client-generated transaction id, so a replayed command is\n * rejected by the storage engine rather than by a read-then-write race. It is on\n * `eventId` — `<transactionId>:<step>` for a transaction — so the claim and the\n * bus's deduplication key are one value rather than two that can drift. The\n * partial one keeps the relay's `pending` query cheap once the collection fills\n * with sent entries; it filters on `quarantined` too, so an entry past its\n * ceiling leaves the index rather than being read and skipped forever.\n *\n * Mongo rejects a `createIndex` that reuses a key pattern with a different\n * `partialFilterExpression` (`IndexOptionsConflict`), and this runs on every\n * sweep and every create. A database that predates the `quarantined` filter\n * therefore fails here on every pass — which is loud only because the sweep now\n * logs what it catches. The fix is `pnpm run <app>:dev:reset`; nothing runs in\n * production, so there is no migration to write.\n */\nexport const ensureOutboxIndexes = (db: Db) =>\n Effect.tryPromise({\n try: async () => {\n const collection = db.collection(OUTBOX_COLLECTION);\n await collection.createIndex({ eventId: 1 }, { unique: true });\n await collection.createIndex(\n { createdAt: 1 },\n { partialFilterExpression: { sent: false, quarantined: false } },\n );\n },\n catch: error =>\n new EntifixConnError('Failed to create outbox indexes', error, {\n db: db.databaseName,\n }),\n });\n\n/**\n * Mongo-backed {@link TransactionOutbox} over one database.\n *\n * `db` is closed over, so every method's Effect has `R = never` — the same\n * technique `makeMongoRepository` uses. There is no publishing here: the relay\n * composes these methods with the event bus, which is what lets the relay be\n * tested without a broker.\n */\nexport const makeMongoOutbox = (db: Db): TransactionOutbox => {\n const collection = db.collection<OutboxEntry>(OUTBOX_COLLECTION);\n\n const fail = (\n message: string,\n error: unknown,\n details?: Record<string, unknown>,\n ) => new EntifixConnError(message, error, details);\n\n return {\n enqueue: event =>\n Effect.tryPromise({\n try: async () => {\n try {\n await collection.insertOne(outboxDocument(event));\n return 'enqueued' as const;\n } catch (error) {\n // Not a failure: the unique index rejected a second claim for this\n // message id, which is precisely how a retry is identified.\n if (isDuplicateKey(error)) {\n return 'duplicate' as const;\n }\n throw error;\n }\n },\n catch: error =>\n fail('Failed to enqueue outbox entry', error, { eventId: event.id }),\n }),\n\n pending: limit =>\n Effect.tryPromise({\n // Oldest first, so `accepted` reaches the bus before the terminal event\n // for the same transaction and the tracker folds them in order.\n try: () =>\n collection\n .find({ sent: false, quarantined: false }, WITHOUT_MONGO_ID)\n .sort({ createdAt: 1 })\n .limit(limit)\n .toArray(),\n catch: error =>\n fail('Failed to read pending outbox entries', error, {\n db: db.databaseName,\n }),\n }),\n\n markSent: entry =>\n Effect.tryPromise({\n try: () =>\n collection.updateOne(\n { eventId: entry.eventId },\n { $set: { sent: true } },\n ),\n catch: error =>\n fail('Failed to mark outbox entry sent', error, {\n eventId: entry.eventId,\n }),\n }).pipe(Effect.asVoid),\n\n stats: () =>\n Effect.tryPromise({\n // Two counts and one `findOne`, rather than reading the entries: the\n // pending set can be arbitrarily large when the relay is stuck, which\n // is exactly the moment this must stay cheap. Both queries ride the\n // partial index on `{ createdAt: 1 }` filtered to the same predicate.\n try: async () => {\n const pendingFilter = { sent: false, quarantined: false };\n const [pending, quarantined, oldest] = await Promise.all([\n collection.countDocuments(pendingFilter),\n collection.countDocuments({ quarantined: true }),\n collection.findOne(pendingFilter, {\n sort: { createdAt: 1 },\n projection: { _id: 0, createdAt: 1 },\n }),\n ]);\n\n return {\n pending,\n quarantined,\n // Absent rather than zero when the outbox is empty: an age of zero\n // and \"nothing is waiting\" are opposite conditions, and a gauge\n // that reports 0 for both is indistinguishable from a healthy relay\n // at exactly the moment it is not one.\n ...(oldest === null ? {} : { oldestPendingAt: oldest.createdAt }),\n };\n },\n catch: error => fail('Failed to read outbox stats', error),\n }),\n\n recordFailure: (entry, error, quarantine) =>\n Effect.tryPromise({\n // `$inc` rather than a read-modify-write: two relays can be draining the\n // same tenant (the request's inline drain and the daemon sweep), and an\n // absolute write would let one of them lose the other's attempt and\n // stretch the ceiling indefinitely.\n try: () =>\n collection.updateOne(\n { eventId: entry.eventId },\n {\n $inc: { attempts: 1 },\n $set: { lastError: error, quarantined: quarantine },\n },\n ),\n catch: cause =>\n fail('Failed to record an outbox publish failure', cause, {\n eventId: entry.eventId,\n }),\n }).pipe(Effect.asVoid),\n };\n};\n"],"names":["EntifixConnError","Effect","OUTBOX_COLLECTION","WITHOUT_MONGO_ID","projection","_id","DUPLICATE_KEY","isDuplicateKey","error","code","outboxDocument","event","eventId","id","sent","attempts","quarantined","createdAt","at","reviveQuarantined","db","tryPromise","try","result","collection","updateOne","$set","$unset","lastError","modifiedCount","catch","databaseName","ensureOutboxIndexes","createIndex","unique","partialFilterExpression","makeMongoOutbox","fail","message","details","enqueue","insertOne","pending","limit","find","sort","toArray","markSent","entry","pipe","asVoid","stats","pendingFilter","oldest","Promise","all","countDocuments","findOne","oldestPendingAt","recordFailure","quarantine","$inc","cause"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/outbox/store.ts"],"sourcesContent":["import type { DomainEvent } from '@entifix/core';\nimport type { OutboxEntry, TransactionOutbox } from '@entifix/transactions';\nimport { Effect } from 'effect';\nimport type { Db } from 'mongodb';\n\nimport { translateMongoError } from '../errors/translate-mongo-error.js';\n\n/**\n * The outbox collection, and it lives in the **same database as the entity it\n * describes** — never in a shared control-plane store.\n *\n * Two reasons, and the second only shows up later. Same database means the\n * entity write and its event are one single-database transaction, which stays a\n * single-*shard* transaction if this ever shards; a control-plane outbox would\n * be cross-database from the first commit. And an outbox holds event payloads:\n * a `TransactionEvent` carries no tenant data, but `catalog.published` carries a\n * whole offering and `order.placed` a whole receipt, and neither may land in the\n * control plane on its way to the bus.\n *\n * That rule is why this lives here rather than in one service. It applies\n * identically to a per-organization `tenant_<id>` and to a single platform-plane\n * database, and the second publisher is what turned a copy into a seam.\n */\nexport const OUTBOX_COLLECTION = 'transaction_outbox';\n\n/** Drop Mongo's internal `_id` from every read. */\nconst WITHOUT_MONGO_ID = { projection: { _id: 0 } } as const;\n\n/** Mongo's duplicate-key error, which is how a replayed command is recognised. */\nconst DUPLICATE_KEY = 11000;\n\nconst isDuplicateKey = (error: unknown): boolean =>\n typeof error === 'object' &&\n error !== null &&\n (error as { code?: unknown }).code === DUPLICATE_KEY;\n\n/**\n * The stored shape of an outbox entry.\n *\n * Exported because the `completed` entry is **not** written through the port:\n * it has to join the entity write's session to be atomic with it, and a session\n * cannot live in a framework-free contract. The handler inserts this document\n * itself; everything else goes through {@link makeMongoOutbox}.\n */\nexport const outboxDocument = (event: DomainEvent): OutboxEntry => ({\n eventId: event.id,\n event,\n sent: false,\n // Defaulted here rather than at each write site because there are two of\n // them: the relay's `enqueue`, and the transaction handler's own insert\n // inside the entity's Mongo session. An entry missing `attempts` would sort\n // into `pending` and then fail `attempts + 1` arithmetic on a `undefined`.\n attempts: 0,\n quarantined: false,\n createdAt: event.at,\n});\n\n/**\n * Puts a **quarantined** entry back in the queue, and touches nothing else.\n *\n * A rebuild walk's repair for the one failure measured on a live lab: with the\n * broker down long enough, the relay spends `outbox.maxAttempts` on an\n * announcement and quarantines it (the deliberate skip-so-the-head-moves),\n * after which the tenant store reads one thing and the storefront shows another\n * with nothing reporting it. Re-emitting cannot help on its own — the event id is\n * `<offeringId>:<publishedAt>` and the unique index answers `duplicate`.\n *\n * ⚠️ **`quarantined: true` is in the filter, not the update.** An entry that is\n * merely pending keeps its `attempts`, and one already `sent` is never re-sent —\n * which is what keeps this from quietly becoming \"re-announce everything\", a\n * walk that would re-publish the whole fleet on every boot and un-quarantine\n * genuine poison each time.\n *\n * Not a `TransactionOutbox` method: the port is framework-free and shared by\n * every publisher, and this is a repair specific to the rebuild. `outboxDocument`\n * is exported beside it for the same class of reason.\n *\n * Answers whether an entry was actually revived.\n */\nexport const reviveQuarantined = (db: Db, eventId: string) =>\n Effect.tryPromise({\n try: async () => {\n const result = await db\n .collection<OutboxEntry>(OUTBOX_COLLECTION)\n .updateOne(\n { eventId, quarantined: true },\n {\n $set: { sent: false, quarantined: false, attempts: 0 },\n // The failure that put it here is spent; leaving it would make the\n // next quarantine's log name an error from a different outage.\n $unset: { lastError: '' },\n },\n );\n return result.modifiedCount === 1;\n },\n catch: error => translateMongoError(error),\n });\n\n/**\n * Ensures the two indexes the outbox depends on.\n *\n * The unique one is load-bearing rather than defensive: it *is* the idempotency\n * check behind the client-generated transaction id, so a replayed command is\n * rejected by the storage engine rather than by a read-then-write race. It is on\n * `eventId` — `<transactionId>:<step>` for a transaction — so the claim and the\n * bus's deduplication key are one value rather than two that can drift. The\n * partial one keeps the relay's `pending` query cheap once the collection fills\n * with sent entries; it filters on `quarantined` too, so an entry past its\n * ceiling leaves the index rather than being read and skipped forever.\n *\n * Mongo rejects a `createIndex` that reuses a key pattern with a different\n * `partialFilterExpression` (`IndexOptionsConflict`), and this runs on every\n * sweep and every create. A database that predates the `quarantined` filter\n * therefore fails here on every pass — which is loud only because the sweep now\n * logs what it catches. The fix is `pnpm run <app>:dev:reset`; nothing runs in\n * production, so there is no migration to write.\n */\nexport const ensureOutboxIndexes = (db: Db) =>\n Effect.tryPromise({\n try: async () => {\n const collection = db.collection(OUTBOX_COLLECTION);\n await collection.createIndex({ eventId: 1 }, { unique: true });\n await collection.createIndex(\n { createdAt: 1 },\n { partialFilterExpression: { sent: false, quarantined: false } },\n );\n },\n catch: error => translateMongoError(error),\n });\n\n/**\n * Mongo-backed {@link TransactionOutbox} over one database.\n *\n * `db` is closed over, so every method's Effect has `R = never` — the same\n * technique `makeMongoRepository` uses. There is no publishing here: the relay\n * composes these methods with the event bus, which is what lets the relay be\n * tested without a broker.\n */\nexport const makeMongoOutbox = (db: Db): TransactionOutbox => {\n const collection = db.collection<OutboxEntry>(OUTBOX_COLLECTION);\n\n return {\n enqueue: event =>\n Effect.tryPromise({\n try: async () => {\n try {\n await collection.insertOne(outboxDocument(event));\n return 'enqueued' as const;\n } catch (error) {\n // Not a failure: the unique index rejected a second claim for this\n // message id, which is precisely how a retry is identified.\n if (isDuplicateKey(error)) {\n return 'duplicate' as const;\n }\n throw error;\n }\n },\n catch: error => translateMongoError(error),\n }),\n\n pending: limit =>\n Effect.tryPromise({\n // Oldest first, so `accepted` reaches the bus before the terminal event\n // for the same transaction and the tracker folds them in order.\n try: () =>\n collection\n .find({ sent: false, quarantined: false }, WITHOUT_MONGO_ID)\n .sort({ createdAt: 1 })\n .limit(limit)\n .toArray(),\n catch: error => translateMongoError(error),\n }),\n\n markSent: entry =>\n Effect.tryPromise({\n try: () =>\n collection.updateOne(\n { eventId: entry.eventId },\n { $set: { sent: true } },\n ),\n catch: error => translateMongoError(error),\n }).pipe(Effect.asVoid),\n\n stats: () =>\n Effect.tryPromise({\n // Two counts and one `findOne`, rather than reading the entries: the\n // pending set can be arbitrarily large when the relay is stuck, which\n // is exactly the moment this must stay cheap. Both queries ride the\n // partial index on `{ createdAt: 1 }` filtered to the same predicate.\n try: async () => {\n const pendingFilter = { sent: false, quarantined: false };\n const [pending, quarantined, oldest] = await Promise.all([\n collection.countDocuments(pendingFilter),\n collection.countDocuments({ quarantined: true }),\n collection.findOne(pendingFilter, {\n sort: { createdAt: 1 },\n projection: { _id: 0, createdAt: 1 },\n }),\n ]);\n\n return {\n pending,\n quarantined,\n // Absent rather than zero when the outbox is empty: an age of zero\n // and \"nothing is waiting\" are opposite conditions, and a gauge\n // that reports 0 for both is indistinguishable from a healthy relay\n // at exactly the moment it is not one.\n ...(oldest === null ? {} : { oldestPendingAt: oldest.createdAt }),\n };\n },\n catch: error => translateMongoError(error),\n }),\n\n recordFailure: (entry, error, quarantine) =>\n Effect.tryPromise({\n // `$inc` rather than a read-modify-write: two relays can be draining the\n // same tenant (the request's inline drain and the daemon sweep), and an\n // absolute write would let one of them lose the other's attempt and\n // stretch the ceiling indefinitely.\n try: () =>\n collection.updateOne(\n { eventId: entry.eventId },\n {\n $inc: { attempts: 1 },\n $set: { lastError: error, quarantined: quarantine },\n },\n ),\n catch: cause => translateMongoError(cause),\n }).pipe(Effect.asVoid),\n };\n};\n"],"names":["Effect","translateMongoError","OUTBOX_COLLECTION","WITHOUT_MONGO_ID","projection","_id","DUPLICATE_KEY","isDuplicateKey","error","code","outboxDocument","event","eventId","id","sent","attempts","quarantined","createdAt","at","reviveQuarantined","db","tryPromise","try","result","collection","updateOne","$set","$unset","lastError","modifiedCount","catch","ensureOutboxIndexes","createIndex","unique","partialFilterExpression","makeMongoOutbox","enqueue","insertOne","pending","limit","find","sort","toArray","markSent","entry","pipe","asVoid","stats","pendingFilter","oldest","Promise","all","countDocuments","findOne","oldestPendingAt","recordFailure","quarantine","$inc","cause"],"mappings":"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"}
|
package/dist/saga/store.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { EntifixConnError } from '@entifix/core';
|
|
2
1
|
import { type SagaStore, SagaStoreTag } from '@entifix/transactions';
|
|
3
2
|
import { Effect, Layer } from 'effect';
|
|
4
3
|
import type { Db } from 'mongodb';
|
|
@@ -14,14 +13,14 @@ export declare const SAGA_COLLECTION = "saga_instances";
|
|
|
14
13
|
* second instance for one flow, and `{ state, updatedAt }` for the sweep —
|
|
15
14
|
* without it every pass is a scan of every saga ever run, on a schedule.
|
|
16
15
|
*/
|
|
17
|
-
export declare const ensureSagaIndexes: (db: Db) => Effect.Effect<void,
|
|
16
|
+
export declare const ensureSagaIndexes: (db: Db) => Effect.Effect<void, import("@entifix/core").StoreFailure, never>;
|
|
18
17
|
/** {@link SagaStore} over one Mongo database. */
|
|
19
18
|
export declare const makeMongoSagaStore: (db: Db) => SagaStore;
|
|
20
19
|
/**
|
|
21
20
|
* Provides {@link SagaStoreTag} from the service's own database, creating the
|
|
22
21
|
* indexes first.
|
|
23
22
|
*/
|
|
24
|
-
export declare const MongoSagaStoreLayer: Layer.Layer<SagaStoreTag,
|
|
23
|
+
export declare const MongoSagaStoreLayer: Layer.Layer<SagaStoreTag, import("@entifix/core").StoreFailure, MongoDatabaseTag>;
|
|
25
24
|
/**
|
|
26
25
|
* Provides {@link SagaStoreTag} from a database the host **names**, resolved on
|
|
27
26
|
* the shared client — for a process whose saga store is one of several stores
|
|
@@ -32,5 +31,5 @@ export declare const MongoSagaStoreLayer: Layer.Layer<SagaStoreTag, EntifixConnE
|
|
|
32
31
|
*/
|
|
33
32
|
export declare const mongoSagaStoreLayer: (options: {
|
|
34
33
|
readonly database: string;
|
|
35
|
-
}) => Layer.Layer<SagaStoreTag,
|
|
34
|
+
}) => Layer.Layer<SagaStoreTag, import("@entifix/core").StoreFailure, MongoClientTag>;
|
|
36
35
|
//# sourceMappingURL=store.d.ts.map
|
package/dist/saga/store.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/saga/store.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/saga/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,SAAS,EACd,YAAY,EACb,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAGlC,OAAO,EACL,cAAc,EACd,gBAAgB,EACjB,MAAM,qCAAqC,CAAC;AAE7C;;;;GAIG;AACH,eAAO,MAAM,eAAe,mBAAmB,CAAC;AAUhD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,IAAI,EAAE,qEAOpC,CAAC;AAEL,iDAAiD;AACjD,eAAO,MAAM,kBAAkB,GAAI,IAAI,EAAE,KAAG,SAoI3C,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mBAAmB,mFAO/B,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,GAAI,SAAS;IAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,oFASvE,CAAC"}
|
package/dist/saga/store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
-
import { EntifixConnError } from '@entifix/core';
|
|
3
2
|
import { SagaStoreTag } from '@entifix/transactions';
|
|
4
3
|
import { Effect, Layer } from 'effect';
|
|
4
|
+
import { translateMongoError } from '../errors/translate-mongo-error.js';
|
|
5
5
|
import { MongoClientTag, MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
6
6
|
/**
|
|
7
7
|
* Saga instances: one document per flow, the durable record a coordinator
|
|
@@ -12,9 +12,6 @@ import { MongoClientTag, MongoDatabaseTag } from '../mongo-database/mongo-databa
|
|
|
12
12
|
'RUNNING',
|
|
13
13
|
'COMPENSATING'
|
|
14
14
|
];
|
|
15
|
-
const failed = (message, sagaId)=>(error)=>new EntifixConnError(message, error, {
|
|
16
|
-
sagaId
|
|
17
|
-
});
|
|
18
15
|
const sagaCollection = (db)=>db.collection(SAGA_COLLECTION);
|
|
19
16
|
const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString();
|
|
20
17
|
/**
|
|
@@ -33,9 +30,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
33
30
|
updatedAt: 1
|
|
34
31
|
});
|
|
35
32
|
},
|
|
36
|
-
catch: (error)=>
|
|
37
|
-
database: db.databaseName
|
|
38
|
-
})
|
|
33
|
+
catch: (error)=>translateMongoError(error)
|
|
39
34
|
});
|
|
40
35
|
/** {@link SagaStore} over one Mongo database. */ export const makeMongoSagaStore = (db)=>{
|
|
41
36
|
const collection = sagaCollection(db);
|
|
@@ -56,7 +51,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
56
51
|
upsert: true
|
|
57
52
|
});
|
|
58
53
|
},
|
|
59
|
-
catch:
|
|
54
|
+
catch: (error)=>translateMongoError(error)
|
|
60
55
|
}),
|
|
61
56
|
beginStep: (sagaId, stepIndex)=>Effect.tryPromise({
|
|
62
57
|
try: async ()=>{
|
|
@@ -69,7 +64,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
69
64
|
}
|
|
70
65
|
});
|
|
71
66
|
},
|
|
72
|
-
catch:
|
|
67
|
+
catch: (error)=>translateMongoError(error)
|
|
73
68
|
}),
|
|
74
69
|
recordOutcome: (sagaId, outcome)=>Effect.tryPromise({
|
|
75
70
|
try: async ()=>{
|
|
@@ -87,7 +82,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
87
82
|
}
|
|
88
83
|
});
|
|
89
84
|
},
|
|
90
|
-
catch:
|
|
85
|
+
catch: (error)=>translateMongoError(error)
|
|
91
86
|
}),
|
|
92
87
|
settle: (sagaId, state, error)=>Effect.tryPromise({
|
|
93
88
|
try: async ()=>{
|
|
@@ -101,7 +96,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
101
96
|
}
|
|
102
97
|
});
|
|
103
98
|
},
|
|
104
|
-
catch:
|
|
99
|
+
catch: (error)=>translateMongoError(error)
|
|
105
100
|
}),
|
|
106
101
|
/**
|
|
107
102
|
* Take ownership of a stale instance, or answer `undefined`.
|
|
@@ -138,7 +133,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
138
133
|
});
|
|
139
134
|
return claimed != null ? claimed : undefined;
|
|
140
135
|
},
|
|
141
|
-
catch:
|
|
136
|
+
catch: (error)=>translateMongoError(error)
|
|
142
137
|
}),
|
|
143
138
|
/**
|
|
144
139
|
* Flag one step's calls as given back, addressing the outcome by its step
|
|
@@ -161,7 +156,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
161
156
|
]
|
|
162
157
|
});
|
|
163
158
|
},
|
|
164
|
-
catch:
|
|
159
|
+
catch: (error)=>translateMongoError(error)
|
|
165
160
|
}),
|
|
166
161
|
get: (sagaId)=>Effect.tryPromise({
|
|
167
162
|
try: async ()=>{
|
|
@@ -174,7 +169,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
174
169
|
}
|
|
175
170
|
})) != null ? _ref : undefined;
|
|
176
171
|
},
|
|
177
|
-
catch:
|
|
172
|
+
catch: (error)=>translateMongoError(error)
|
|
178
173
|
}),
|
|
179
174
|
/**
|
|
180
175
|
* Instances stuck mid-flight past a deadline. ⚠️ `COMPENSATING` counts: a
|
|
@@ -195,7 +190,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
195
190
|
_id: 0
|
|
196
191
|
}
|
|
197
192
|
}).toArray(),
|
|
198
|
-
catch:
|
|
193
|
+
catch: (error)=>translateMongoError(error)
|
|
199
194
|
})
|
|
200
195
|
};
|
|
201
196
|
};
|