@entifix/mongo 0.5.0 → 0.6.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/dist/mongo-database/mongo-database.d.ts +1 -1
- package/dist/mongo-database/mongo-database.js +1 -1
- package/dist/mongo-database/mongo-database.js.map +1 -1
- package/dist/mongo-database/mongo-health-probe.d.ts +2 -2
- package/dist/mongo-database/mongo-health-probe.js +2 -2
- package/dist/mongo-database/mongo-health-probe.js.map +1 -1
- package/dist/outbox/metrics.d.ts +3 -2
- package/dist/outbox/metrics.d.ts.map +1 -1
- package/dist/outbox/metrics.js +3 -2
- package/dist/outbox/metrics.js.map +1 -1
- package/dist/outbox/relay.d.ts +1 -1
- package/dist/outbox/relay.js +1 -1
- package/dist/outbox/relay.js.map +1 -1
- package/dist/outbox/store.d.ts +3 -4
- package/dist/outbox/store.d.ts.map +1 -1
- package/dist/outbox/store.js +3 -4
- package/dist/outbox/store.js.map +1 -1
- package/dist/saga/store.d.ts +2 -1
- package/dist/saga/store.d.ts.map +1 -1
- package/dist/saga/store.js +2 -1
- package/dist/saga/store.js.map +1 -1
- package/dist/transactions/index.d.ts +1 -1
- package/dist/transactions/index.js +1 -1
- package/dist/transactions/index.js.map +1 -1
- package/package.json +6 -6
|
@@ -33,7 +33,7 @@ export interface MongoDatabaseSettings extends MongoClientSettings {
|
|
|
33
33
|
* This is the layer to reach for when a service names **no** database at boot —
|
|
34
34
|
* one that resolves every handle per request (tenant storage) or holds several
|
|
35
35
|
* named stores in one process. Naming a database it never writes would put a
|
|
36
|
-
* phantom store in the register
|
|
36
|
+
* phantom store in the host's register — a store nothing writes.
|
|
37
37
|
*
|
|
38
38
|
* {@link MongoDatabaseLayer} builds on this rather than connecting again: two
|
|
39
39
|
* layers over the same URI would mean two pools.
|
|
@@ -23,7 +23,7 @@ import { MongoClient } from 'mongodb';
|
|
|
23
23
|
* This is the layer to reach for when a service names **no** database at boot —
|
|
24
24
|
* one that resolves every handle per request (tenant storage) or holds several
|
|
25
25
|
* named stores in one process. Naming a database it never writes would put a
|
|
26
|
-
* phantom store in the register
|
|
26
|
+
* phantom store in the host's register — a store nothing writes.
|
|
27
27
|
*
|
|
28
28
|
* {@link MongoDatabaseLayer} builds on this rather than connecting again: two
|
|
29
29
|
* layers over the same URI would mean two pools.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/mongo-database/mongo-database.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport { Context, Effect, Layer, Schedule } from 'effect';\nimport { Db, MongoClient } from 'mongodb';\n\n/** DI tag carrying a connected mongodb {@link Db} handle. */\nexport class MongoDatabaseTag extends Context.Service<MongoDatabaseTag, Db>()(\n 'MongoDatabaseTag',\n) {}\n\n/**\n * DI tag carrying the connected {@link MongoClient} itself — the **pool**.\n *\n * Exposed alongside {@link MongoDatabaseTag} so a per-organization handle can be\n * resolved with `client.db(name)` inside a request without opening a second\n * connection. Both tags come from one acquire for exactly that reason: two\n * layers over the same URI would mean two pools.\n *\n * Reach for {@link MongoDatabaseTag} unless you are resolving tenant storage.\n */\nexport class MongoClientTag extends Context.Service<\n MongoClientTag,\n MongoClient\n>()('MongoClientTag') {}\n\nexport interface MongoClientSettings {\n readonly uri: string;\n}\n\nexport interface MongoDatabaseSettings extends MongoClientSettings {\n readonly dbName: string;\n}\n\n/**\n * A scoped {@link Layer} that opens a {@link MongoClient} on acquire and closes\n * it on release, exposing it under {@link MongoClientTag}. Because it is scoped,\n * the connection is torn down deterministically when the service's root layer is\n * interrupted (the `makeService` graceful-shutdown path).\n *\n * This is the layer to reach for when a service names **no** database at boot —\n * one that resolves every handle per request (tenant storage) or holds several\n * named stores in one process. Naming a database it never writes would put a\n * phantom store in the register
|
|
1
|
+
{"version":3,"sources":["../../src/mongo-database/mongo-database.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport { Context, Effect, Layer, Schedule } from 'effect';\nimport { Db, MongoClient } from 'mongodb';\n\n/** DI tag carrying a connected mongodb {@link Db} handle. */\nexport class MongoDatabaseTag extends Context.Service<MongoDatabaseTag, Db>()(\n 'MongoDatabaseTag',\n) {}\n\n/**\n * DI tag carrying the connected {@link MongoClient} itself — the **pool**.\n *\n * Exposed alongside {@link MongoDatabaseTag} so a per-organization handle can be\n * resolved with `client.db(name)` inside a request without opening a second\n * connection. Both tags come from one acquire for exactly that reason: two\n * layers over the same URI would mean two pools.\n *\n * Reach for {@link MongoDatabaseTag} unless you are resolving tenant storage.\n */\nexport class MongoClientTag extends Context.Service<\n MongoClientTag,\n MongoClient\n>()('MongoClientTag') {}\n\nexport interface MongoClientSettings {\n readonly uri: string;\n}\n\nexport interface MongoDatabaseSettings extends MongoClientSettings {\n readonly dbName: string;\n}\n\n/**\n * A scoped {@link Layer} that opens a {@link MongoClient} on acquire and closes\n * it on release, exposing it under {@link MongoClientTag}. Because it is scoped,\n * the connection is torn down deterministically when the service's root layer is\n * interrupted (the `makeService` graceful-shutdown path).\n *\n * This is the layer to reach for when a service names **no** database at boot —\n * one that resolves every handle per request (tenant storage) or holds several\n * named stores in one process. Naming a database it never writes would put a\n * phantom store in the host's register — a store nothing writes.\n *\n * {@link MongoDatabaseLayer} builds on this rather than connecting again: two\n * layers over the same URI would mean two pools.\n */\nexport const MongoClientLayer = (\n settings: MongoClientSettings,\n): Layer.Layer<MongoClientTag, EntifixConnError> =>\n Layer.effect(\n MongoClientTag,\n Effect.acquireRelease(\n Effect.tryPromise({\n try: () =>\n MongoClient.connect(settings.uri, {\n // The 30s default means a readiness probe against a missing\n // primary sits for 30s; the registry would time it out first and\n // report a truthful `false`, but the driver would keep the fiber\n // alive behind it. Fail fast instead.\n serverSelectionTimeoutMS: 5_000,\n }),\n catch: error =>\n new EntifixConnError('Failed to connect to MongoDB', error),\n }).pipe(\n // Boot order is not guaranteed — a service may start while Mongo is\n // still rolling out. Keep trying for a window instead of dying on the\n // first refusal, which leaves a dead process nothing will restart.\n Effect.retry(\n Schedule.exponential('250 millis').pipe(\n Schedule.upTo({ duration: '30 seconds' }),\n ),\n ),\n ),\n client => Effect.promise(() => client.close()),\n ),\n );\n\n/**\n * {@link MongoClientLayer} plus the named {@link Db} resolved from it, exposed\n * under {@link MongoDatabaseTag}. One acquire, one pool, both tags — which is\n * why a service that wants both must not merge the two layers.\n *\n * Use it when the service owns exactly one named store (`auth-service`). A\n * service whose handles are all per-request wants {@link MongoClientLayer}.\n */\nexport const MongoDatabaseLayer = (\n settings: MongoDatabaseSettings,\n): Layer.Layer<MongoDatabaseTag | MongoClientTag, EntifixConnError> =>\n Layer.provideMerge(\n Layer.effect(\n MongoDatabaseTag,\n Effect.map(MongoClientTag, client => client.db(settings.dbName)),\n ),\n MongoClientLayer(settings),\n );\n"],"names":["EntifixConnError","Context","Effect","Layer","Schedule","MongoClient","MongoDatabaseTag","Service","MongoClientTag","MongoClientLayer","settings","effect","acquireRelease","tryPromise","try","connect","uri","serverSelectionTimeoutMS","catch","error","pipe","retry","exponential","upTo","duration","client","promise","close","MongoDatabaseLayer","provideMerge","map","db","dbName"],"mappings":"AAAA,SAASA,gBAAgB,QAAQ,gBAAgB;AACjD,SAASC,OAAO,EAAEC,MAAM,EAAEC,KAAK,EAAEC,QAAQ,QAAQ,SAAS;AAC1D,SAAaC,WAAW,QAAQ,UAAU;AAE1C,2DAA2D,GAC3D,OAAO,MAAMC,yBAAyBL,QAAQM,OAAO,GACnD;AACC;AAEH;;;;;;;;;CASC,GACD,OAAO,MAAMC,uBAAuBP,QAAQM,OAAO,GAG/C;AAAmB;AAUvB;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAME,mBAAmB,CAC9BC,WAEAP,MAAMQ,MAAM,CACVH,gBACAN,OAAOU,cAAc,CACnBV,OAAOW,UAAU,CAAC;QAChBC,KAAK,IACHT,YAAYU,OAAO,CAACL,SAASM,GAAG,EAAE;gBAChC,4DAA4D;gBAC5D,iEAAiE;gBACjE,iEAAiE;gBACjE,sCAAsC;gBACtCC,0BAA0B;YAC5B;QACFC,OAAOC,CAAAA,QACL,IAAInB,iBAAiB,gCAAgCmB;IACzD,GAAGC,IAAI,CACL,oEAAoE;IACpE,sEAAsE;IACtE,mEAAmE;IACnElB,OAAOmB,KAAK,CACVjB,SAASkB,WAAW,CAAC,cAAcF,IAAI,CACrChB,SAASmB,IAAI,CAAC;QAAEC,UAAU;IAAa,OAI7CC,CAAAA,SAAUvB,OAAOwB,OAAO,CAAC,IAAMD,OAAOE,KAAK,MAE7C;AAEJ;;;;;;;CAOC,GACD,OAAO,MAAMC,qBAAqB,CAChClB,WAEAP,MAAM0B,YAAY,CAChB1B,MAAMQ,MAAM,CACVL,kBACAJ,OAAO4B,GAAG,CAACtB,gBAAgBiB,CAAAA,SAAUA,OAAOM,EAAE,CAACrB,SAASsB,MAAM,KAEhEvB,iBAAiBC,WACjB"}
|
|
@@ -12,7 +12,7 @@ export declare const MONGO_PROBE_NAME = "mongo";
|
|
|
12
12
|
* ping targets the `admin` database and never touches the connected one — so a
|
|
13
13
|
* service whose handles are all per-request (tenant storage) does not have to
|
|
14
14
|
* invent a database name to become observable. Naming one it never writes is
|
|
15
|
-
* exactly the phantom store
|
|
15
|
+
* exactly the phantom store a register should never hold.
|
|
16
16
|
*
|
|
17
17
|
* Merge it beside {@link MongoClientLayer} in a service's `AppLayer` and the
|
|
18
18
|
* service's readiness endpoint starts reporting Mongo — nothing in the service
|
|
@@ -22,7 +22,7 @@ export declare const MONGO_PROBE_NAME = "mongo";
|
|
|
22
22
|
* `tools/slices/` — `['catalog', 'saga']`, never a database name and never a
|
|
23
23
|
* URI. It is the composition root's to supply because a client package cannot
|
|
24
24
|
* know it, and it is a list because one connection routinely backs several
|
|
25
|
-
* Stores
|
|
25
|
+
* Stores.
|
|
26
26
|
*/
|
|
27
27
|
export declare const MongoHealthProbeLayer: (stores: readonly string[]) => Layer.Layer<never, never, MongoClientTag | HealthRegistryTag>;
|
|
28
28
|
//# sourceMappingURL=mongo-health-probe.d.ts.map
|
|
@@ -11,7 +11,7 @@ import { MongoClientTag } from './mongo-database.js';
|
|
|
11
11
|
* ping targets the `admin` database and never touches the connected one — so a
|
|
12
12
|
* service whose handles are all per-request (tenant storage) does not have to
|
|
13
13
|
* invent a database name to become observable. Naming one it never writes is
|
|
14
|
-
* exactly the phantom store
|
|
14
|
+
* exactly the phantom store a register should never hold.
|
|
15
15
|
*
|
|
16
16
|
* Merge it beside {@link MongoClientLayer} in a service's `AppLayer` and the
|
|
17
17
|
* service's readiness endpoint starts reporting Mongo — nothing in the service
|
|
@@ -21,7 +21,7 @@ import { MongoClientTag } from './mongo-database.js';
|
|
|
21
21
|
* `tools/slices/` — `['catalog', 'saga']`, never a database name and never a
|
|
22
22
|
* URI. It is the composition root's to supply because a client package cannot
|
|
23
23
|
* know it, and it is a list because one connection routinely backs several
|
|
24
|
-
* Stores
|
|
24
|
+
* Stores.
|
|
25
25
|
*/ export const MongoHealthProbeLayer = (stores)=>Layer.effectDiscard(Effect.gen(function*() {
|
|
26
26
|
const client = yield* MongoClientTag;
|
|
27
27
|
const registry = yield* HealthRegistryTag;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/mongo-database/mongo-health-probe.ts"],"sourcesContent":["import { HealthRegistryTag } from '@entifix/business';\nimport { Effect, Layer } from 'effect';\n\nimport { MongoClientTag } from './mongo-database.js';\n\n/** Probe name reported by `/api/health/ready` when Mongo is unreachable. */\nexport const MONGO_PROBE_NAME = 'mongo';\n\n/**\n * Registers a readiness probe for the Mongo **connection**: `admin().ping()`,\n * which is the cheapest round trip the driver offers and, unlike \"is the client\n * object non-null\", actually crosses the wire.\n *\n * It depends on {@link MongoClientTag}, not on a named database, because the\n * ping targets the `admin` database and never touches the connected one — so a\n * service whose handles are all per-request (tenant storage) does not have to\n * invent a database name to become observable. Naming one it never writes is\n * exactly the phantom store
|
|
1
|
+
{"version":3,"sources":["../../src/mongo-database/mongo-health-probe.ts"],"sourcesContent":["import { HealthRegistryTag } from '@entifix/business';\nimport { Effect, Layer } from 'effect';\n\nimport { MongoClientTag } from './mongo-database.js';\n\n/** Probe name reported by `/api/health/ready` when Mongo is unreachable. */\nexport const MONGO_PROBE_NAME = 'mongo';\n\n/**\n * Registers a readiness probe for the Mongo **connection**: `admin().ping()`,\n * which is the cheapest round trip the driver offers and, unlike \"is the client\n * object non-null\", actually crosses the wire.\n *\n * It depends on {@link MongoClientTag}, not on a named database, because the\n * ping targets the `admin` database and never touches the connected one — so a\n * service whose handles are all per-request (tenant storage) does not have to\n * invent a database name to become observable. Naming one it never writes is\n * exactly the phantom store a register should never hold.\n *\n * Merge it beside {@link MongoClientLayer} in a service's `AppLayer` and the\n * service's readiness endpoint starts reporting Mongo — nothing in the service\n * describes the probe, so it cannot drift from the dependency it describes.\n *\n * `stores` names the Stores this connection backs, by their register name in\n * `tools/slices/` — `['catalog', 'saga']`, never a database name and never a\n * URI. It is the composition root's to supply because a client package cannot\n * know it, and it is a list because one connection routinely backs several\n * Stores.\n */\nexport const MongoHealthProbeLayer = (\n stores: readonly string[],\n): Layer.Layer<never, never, MongoClientTag | HealthRegistryTag> =>\n Layer.effectDiscard(\n Effect.gen(function* () {\n const client = yield* MongoClientTag;\n const registry = yield* HealthRegistryTag;\n\n yield* registry.register({\n name: MONGO_PROBE_NAME,\n kind: 'datastore',\n targets: stores,\n check: Effect.tryPromise(() =>\n client.db('admin').command({ ping: 1 }),\n ).pipe(\n Effect.as(true),\n // Unreachable is a `false`, never a failed endpoint.\n Effect.catch(() => Effect.succeed(false)),\n ),\n });\n }),\n );\n"],"names":["HealthRegistryTag","Effect","Layer","MongoClientTag","MONGO_PROBE_NAME","MongoHealthProbeLayer","stores","effectDiscard","gen","client","registry","register","name","kind","targets","check","tryPromise","db","command","ping","pipe","as","catch","succeed"],"mappings":"AAAA,SAASA,iBAAiB,QAAQ,oBAAoB;AACtD,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAEvC,SAASC,cAAc,QAAQ,sBAAsB;AAErD,0EAA0E,GAC1E,OAAO,MAAMC,mBAAmB,QAAQ;AAExC;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAMC,wBAAwB,CACnCC,SAEAJ,MAAMK,aAAa,CACjBN,OAAOO,GAAG,CAAC;QACT,MAAMC,SAAS,OAAON;QACtB,MAAMO,WAAW,OAAOV;QAExB,OAAOU,SAASC,QAAQ,CAAC;YACvBC,MAAMR;YACNS,MAAM;YACNC,SAASR;YACTS,OAAOd,OAAOe,UAAU,CAAC,IACvBP,OAAOQ,EAAE,CAAC,SAASC,OAAO,CAAC;oBAAEC,MAAM;gBAAE,IACrCC,IAAI,CACJnB,OAAOoB,EAAE,CAAC,OACV,qDAAqD;YACrDpB,OAAOqB,KAAK,CAAC,IAAMrB,OAAOsB,OAAO,CAAC;QAEtC;IACF,IACA"}
|
package/dist/outbox/metrics.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { OutboxStats } from '@entifix/transactions';
|
|
2
2
|
import { Effect, Metric } from 'effect';
|
|
3
3
|
/**
|
|
4
|
-
* The outbox half of
|
|
4
|
+
* The outbox half of the framework's first metric set; the bus half is in
|
|
5
|
+
* `@entifix/amqp`.
|
|
5
6
|
*
|
|
6
7
|
* It lives beside the relay that samples it rather than in any one service,
|
|
7
8
|
* because three slices now own an outbox — `catalog`, `order` and `payment` —
|
|
@@ -31,7 +32,7 @@ export declare const outboxPending: Metric.Gauge<number>;
|
|
|
31
32
|
/**
|
|
32
33
|
* Age in seconds of the oldest unsent entry, per database.
|
|
33
34
|
*
|
|
34
|
-
* **The metric that makes a stuck relay visible.**
|
|
35
|
+
* **The metric that makes a stuck relay visible.** Quarantine gave the relay a
|
|
35
36
|
* ceiling, so an entry that can never publish is quarantined and skipped rather
|
|
36
37
|
* than blocking the head of the line forever — but that is reported only as a
|
|
37
38
|
* log line, so how far behind the outbox has fallen is otherwise unanswerable.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../../src/outbox/metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAExC
|
|
1
|
+
{"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../../src/outbox/metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAExC;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,oDAAoD;AACpD,eAAO,MAAM,aAAa,sBAExB,CAAC;AAEH;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAkBvD,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,iBAAiB,sBAE5B,CAAC;AAEH,yCAAyC;AACzC,eAAO,MAAM,iBAAiB,GAAI,UAAU,MAAM,EAAE,OAAO,WAAW,sCAgBrE,CAAC"}
|
package/dist/outbox/metrics.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Effect, Metric } from 'effect';
|
|
2
2
|
/**
|
|
3
|
-
* The outbox half of
|
|
3
|
+
* The outbox half of the framework's first metric set; the bus half is in
|
|
4
|
+
* `@entifix/amqp`.
|
|
4
5
|
*
|
|
5
6
|
* It lives beside the relay that samples it rather than in any one service,
|
|
6
7
|
* because three slices now own an outbox — `catalog`, `order` and `payment` —
|
|
@@ -30,7 +31,7 @@ import { Effect, Metric } from 'effect';
|
|
|
30
31
|
/**
|
|
31
32
|
* Age in seconds of the oldest unsent entry, per database.
|
|
32
33
|
*
|
|
33
|
-
* **The metric that makes a stuck relay visible.**
|
|
34
|
+
* **The metric that makes a stuck relay visible.** Quarantine gave the relay a
|
|
34
35
|
* ceiling, so an entry that can never publish is quarantined and skipped rather
|
|
35
36
|
* than blocking the head of the line forever — but that is reported only as a
|
|
36
37
|
* log line, so how far behind the outbox has fallen is otherwise unanswerable.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/outbox/metrics.ts"],"sourcesContent":["import type { OutboxStats } from '@entifix/transactions';\nimport { Effect, Metric } from 'effect';\n\n/**\n * The outbox half of
|
|
1
|
+
{"version":3,"sources":["../../src/outbox/metrics.ts"],"sourcesContent":["import type { OutboxStats } from '@entifix/transactions';\nimport { Effect, Metric } from 'effect';\n\n/**\n * The outbox half of the framework's first metric set; the bus half is in\n * `@entifix/amqp`.\n *\n * It lives beside the relay that samples it rather than in any one service,\n * because three slices now own an outbox — `catalog`, `order` and `payment` —\n * and the warning below is exactly why a copy per service is not an option: an\n * Effect metric is keyed on its **description**, so three hand-maintained\n * definitions of `outbox_pending_entries` are three series the moment one\n * wording drifts, and a dashboard would go quiet without failing anything.\n *\n * ⚠️ The metric objects are **exported so a reader uses the same instance**. An\n * Effect metric's registry key includes its description, so rebuilding one by\n * name without the description addresses a different series that is permanently\n * zero — a silent way to write a query against nothing.\n *\n * These are gauges rather than counters because they describe a *backlog*: the\n * question is how many are waiting right now, not how many there have ever\n * been. They are sampled by the two daemons that already run on an interval, so\n * nothing new is scheduled to produce them.\n *\n * ⚠️ **A dimensionless gauge reaches Prometheus with a `_ratio` suffix.** That\n * is the OTel exporter's convention for unit `1`, not a mistake here, so\n * `outbox_pending_entries` is queried as `outbox_pending_entries_ratio`.\n * Counters are unaffected. Where a metric has a real unit, tag it — see\n * {@link outboxOldestPendingAge}.\n */\n\n/** Unsent, un-quarantined entries, per database. */\nexport const outboxPending = Metric.gauge('outbox_pending_entries', {\n description: 'Unsent, un-quarantined outbox entries, by database.',\n});\n\n/**\n * Age in seconds of the oldest unsent entry, per database.\n *\n * **The metric that makes a stuck relay visible.** Quarantine gave the relay a\n * ceiling, so an entry that can never publish is quarantined and skipped rather\n * than blocking the head of the line forever — but that is reported only as a\n * log line, so how far behind the outbox has fallen is otherwise unanswerable.\n * A depth that is flat and an age that climbs is the signature of a relay whose\n * head has stopped moving.\n */\nexport const outboxOldestPendingAge: Metric.Gauge<number> = Metric.gauge(\n 'outbox_oldest_pending_age',\n {\n description:\n 'Age of the oldest unsent outbox entry, by database. Zero when ' +\n 'the outbox is empty.',\n },\n).pipe(\n // ⚠️ **The unit is carried as a tag, and that is the only way to set it.**\n // `@effect/opentelemetry`'s producer reads `tags.unit ?? tags.time_unit ??\n // '1'`, so a gauge with no `unit` tag is exported as dimensionless — and the\n // Prometheus exporter then appends `_ratio` to a dimensionless gauge's name.\n // Measured: this series arrived as `outbox_oldest_pending_age_seconds_ratio`,\n // a duration announcing itself as a ratio. Tagging it makes the exporter name\n // it `_seconds`, which is why the name above no longer carries that suffix\n // itself. The cost is a constant `unit` label, because the producer builds the\n // datapoint attributes from the same tag set it reads the unit from.\n Metric.withAttributes({ unit: 's' }),\n);\n\n/** Entries past the ceiling, which nothing retries and nothing deletes. */\nexport const outboxQuarantined = Metric.gauge('outbox_quarantined_entries', {\n description: 'Quarantined outbox entries, by database.',\n});\n\n/** Record one outbox's depth and age. */\nexport const recordOutboxStats = (database: string, stats: OutboxStats) => {\n const tagged = (metric: Metric.Gauge<number>) =>\n Metric.withAttributes(metric, { database });\n\n // Zero when nothing is waiting. The alternative — not reporting the series —\n // reads on most dashboards as \"no data\", which is indistinguishable from the\n // exporter having broken at exactly the moment a healthy relay looks idle.\n const ageSeconds =\n stats.oldestPendingAt === undefined\n ? 0\n : Math.max(0, (Date.now() - Date.parse(stats.oldestPendingAt)) / 1000);\n\n return Metric.update(tagged(outboxPending), stats.pending).pipe(\n Effect.andThen(Metric.update(tagged(outboxOldestPendingAge), ageSeconds)),\n Effect.andThen(Metric.update(tagged(outboxQuarantined), stats.quarantined)),\n );\n};\n"],"names":["Effect","Metric","outboxPending","gauge","description","outboxOldestPendingAge","pipe","withAttributes","unit","outboxQuarantined","recordOutboxStats","database","stats","tagged","metric","ageSeconds","oldestPendingAt","undefined","Math","max","Date","now","parse","update","pending","andThen","quarantined"],"mappings":"AACA,SAASA,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAExC;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,kDAAkD,GAClD,OAAO,MAAMC,gBAAgBD,OAAOE,KAAK,CAAC,0BAA0B;IAClEC,aAAa;AACf,GAAG;AAEH;;;;;;;;;CASC,GACD,OAAO,MAAMC,yBAA+CJ,OAAOE,KAAK,CACtE,6BACA;IACEC,aACE,mEACA;AACJ,GACAE,IAAI,CACJ,2EAA2E;AAC3E,2EAA2E;AAC3E,6EAA6E;AAC7E,6EAA6E;AAC7E,8EAA8E;AAC9E,8EAA8E;AAC9E,2EAA2E;AAC3E,+EAA+E;AAC/E,qEAAqE;AACrEL,OAAOM,cAAc,CAAC;IAAEC,MAAM;AAAI,IAClC;AAEF,yEAAyE,GACzE,OAAO,MAAMC,oBAAoBR,OAAOE,KAAK,CAAC,8BAA8B;IAC1EC,aAAa;AACf,GAAG;AAEH,uCAAuC,GACvC,OAAO,MAAMM,oBAAoB,CAACC,UAAkBC;IAClD,MAAMC,SAAS,CAACC,SACdb,OAAOM,cAAc,CAACO,QAAQ;YAAEH;QAAS;IAE3C,6EAA6E;IAC7E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMI,aACJH,MAAMI,eAAe,KAAKC,YACtB,IACAC,KAAKC,GAAG,CAAC,GAAG,AAACC,CAAAA,KAAKC,GAAG,KAAKD,KAAKE,KAAK,CAACV,MAAMI,eAAe,CAAA,IAAK;IAErE,OAAOf,OAAOsB,MAAM,CAACV,OAAOX,gBAAgBU,MAAMY,OAAO,EAAElB,IAAI,CAC7DN,OAAOyB,OAAO,CAACxB,OAAOsB,MAAM,CAACV,OAAOR,yBAAyBU,cAC7Df,OAAOyB,OAAO,CAACxB,OAAOsB,MAAM,CAACV,OAAOJ,oBAAoBG,MAAMc,WAAW;AAE7E,EAAE"}
|
package/dist/outbox/relay.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ export interface DrainOptions {
|
|
|
37
37
|
* - A **still-deliverable** failure returns immediately rather than continuing.
|
|
38
38
|
* The entries behind this one keep their order relative to it, which is what
|
|
39
39
|
* stops a terminal event overtaking the `accepted` for the same transaction.
|
|
40
|
-
* - A **quarantined** failure continues. That is
|
|
40
|
+
* - A **quarantined** failure continues. That is the deliberate
|
|
41
41
|
* skip-so-the-head-moves: an entry that can never publish must stop blocking
|
|
42
42
|
* every entry behind it forever.
|
|
43
43
|
*/
|
package/dist/outbox/relay.js
CHANGED
|
@@ -30,7 +30,7 @@ import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
|
30
30
|
* - A **still-deliverable** failure returns immediately rather than continuing.
|
|
31
31
|
* The entries behind this one keep their order relative to it, which is what
|
|
32
32
|
* stops a terminal event overtaking the `accepted` for the same transaction.
|
|
33
|
-
* - A **quarantined** failure continues. That is
|
|
33
|
+
* - A **quarantined** failure continues. That is the deliberate
|
|
34
34
|
* skip-so-the-head-moves: an entry that can never publish must stop blocking
|
|
35
35
|
* every entry behind it forever.
|
|
36
36
|
*/ export const drainOutbox = (outbox, bus, options)=>Effect.gen(function*() {
|
package/dist/outbox/relay.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/outbox/relay.ts"],"sourcesContent":["import { ShutdownRegistryTag } 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';\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
|
|
1
|
+
{"version":3,"sources":["../../src/outbox/relay.ts"],"sourcesContent":["import { ShutdownRegistryTag } 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';\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 marketplace-admin's tenant walk. 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 const shutdown = yield* ShutdownRegistryTag;\n\n yield* ensureOutboxIndexes(db);\n const outbox = makeMongoOutbox(db);\n\n const sweepOnce = sweepOutbox(outbox, bus, {\n maxAttempts,\n database: db.databaseName,\n ...(options?.onStats ? { onStats: options.onStats } : {}),\n });\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"],"names":["ShutdownRegistryTag","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","shutdown","sweepOnce","databaseName","daemon","forkDetach","delay","forever","register","phase","run","interrupt","andThen"],"mappings":"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"}
|
package/dist/outbox/store.d.ts
CHANGED
|
@@ -16,8 +16,7 @@ import type { Db } from 'mongodb';
|
|
|
16
16
|
*
|
|
17
17
|
* That rule is why this lives here rather than in one service. It applies
|
|
18
18
|
* identically to a per-organization `tenant_<id>` and to a single platform-plane
|
|
19
|
-
* database, and the second publisher is what turned a copy into a seam
|
|
20
|
-
* ([ADR 0028](../../../../../../docs/adr/0028-the-transaction-id-is-the-clients-and-its-event-ships-with-the-write.md)).
|
|
19
|
+
* database, and the second publisher is what turned a copy into a seam.
|
|
21
20
|
*/
|
|
22
21
|
export declare const OUTBOX_COLLECTION = "transaction_outbox";
|
|
23
22
|
/**
|
|
@@ -32,9 +31,9 @@ export declare const outboxDocument: (event: DomainEvent) => OutboxEntry;
|
|
|
32
31
|
/**
|
|
33
32
|
* Puts a **quarantined** entry back in the queue, and touches nothing else.
|
|
34
33
|
*
|
|
35
|
-
*
|
|
34
|
+
* A rebuild walk's repair for the one failure measured on a live lab: with the
|
|
36
35
|
* broker down long enough, the relay spends `outbox.maxAttempts` on an
|
|
37
|
-
* announcement and quarantines it (
|
|
36
|
+
* announcement and quarantines it (the deliberate skip-so-the-head-moves),
|
|
38
37
|
* after which the tenant store reads one thing and the storefront shows another
|
|
39
38
|
* with nothing reporting it. Re-emitting cannot help on its own — the event id is
|
|
40
39
|
* `<offeringId>:<publishedAt>` and the unique index answers `duplicate`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/outbox/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACnE,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;AAElC
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/outbox/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACnE,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;AAElC;;;;;;;;;;;;;;;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,oDAyBrD,CAAC;AAEL;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,mBAAmB,GAAI,IAAI,EAAE,iDActC,CAAC;AAEL;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAAI,IAAI,EAAE,KAAG,iBA4GxC,CAAC"}
|
package/dist/outbox/store.js
CHANGED
|
@@ -15,8 +15,7 @@ import { Effect } from 'effect';
|
|
|
15
15
|
*
|
|
16
16
|
* That rule is why this lives here rather than in one service. It applies
|
|
17
17
|
* identically to a per-organization `tenant_<id>` and to a single platform-plane
|
|
18
|
-
* database, and the second publisher is what turned a copy into a seam
|
|
19
|
-
* ([ADR 0028](../../../../../../docs/adr/0028-the-transaction-id-is-the-clients-and-its-event-ships-with-the-write.md)).
|
|
18
|
+
* database, and the second publisher is what turned a copy into a seam.
|
|
20
19
|
*/ export const OUTBOX_COLLECTION = 'transaction_outbox';
|
|
21
20
|
/** Drop Mongo's internal `_id` from every read. */ const WITHOUT_MONGO_ID = {
|
|
22
21
|
projection: {
|
|
@@ -47,9 +46,9 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
47
46
|
/**
|
|
48
47
|
* Puts a **quarantined** entry back in the queue, and touches nothing else.
|
|
49
48
|
*
|
|
50
|
-
*
|
|
49
|
+
* A rebuild walk's repair for the one failure measured on a live lab: with the
|
|
51
50
|
* broker down long enough, the relay spends `outbox.maxAttempts` on an
|
|
52
|
-
* announcement and quarantines it (
|
|
51
|
+
* announcement and quarantines it (the deliberate skip-so-the-head-moves),
|
|
53
52
|
* after which the tenant store reads one thing and the storefront shows another
|
|
54
53
|
* with nothing reporting it. Re-emitting cannot help on its own — the event id is
|
|
55
54
|
* `<offeringId>:<publishedAt>` and the unique index answers `duplicate`.
|
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 * ([ADR 0028](../../../../../../docs/adr/0028-the-transaction-id-is-the-clients-and-its-event-ships-with-the-write.md)).\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 * The rebuild walk's repair for the one failure ADR 0048 measured: with the\n * broker down long enough, the relay spends `outbox.maxAttempts` on an\n * announcement and quarantines it (ADR 0030's 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, 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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"}
|
package/dist/saga/store.d.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { Db } from 'mongodb';
|
|
|
5
5
|
import { MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
6
6
|
/**
|
|
7
7
|
* Saga instances: one document per flow, the durable record a coordinator
|
|
8
|
-
* resumes from
|
|
8
|
+
* resumes from, so where to resume is derived from the outcomes rather than
|
|
9
|
+
* stored beside them.
|
|
9
10
|
*/
|
|
10
11
|
export declare const SAGA_COLLECTION = "saga_instances";
|
|
11
12
|
/**
|
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,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,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;AAElC,OAAO,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAEvE
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/saga/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,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;AAElC,OAAO,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAEvE;;;;GAIG;AACH,eAAO,MAAM,eAAe,mBAAmB,CAAC;AAahD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,IAAI,EAAE,iDAUpC,CAAC;AAEL,iDAAiD;AACjD,eAAO,MAAM,kBAAkB,GAAI,IAAI,EAAE,KAAG,SAoI3C,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mBAAmB,+DAO/B,CAAC"}
|
package/dist/saga/store.js
CHANGED
|
@@ -5,7 +5,8 @@ import { Effect, Layer } from 'effect';
|
|
|
5
5
|
import { MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
6
6
|
/**
|
|
7
7
|
* Saga instances: one document per flow, the durable record a coordinator
|
|
8
|
-
* resumes from
|
|
8
|
+
* resumes from, so where to resume is derived from the outcomes rather than
|
|
9
|
+
* stored beside them.
|
|
9
10
|
*/ export const SAGA_COLLECTION = 'saga_instances';
|
|
10
11
|
/** The two states a sweep treats as unfinished. */ const UNFINISHED = [
|
|
11
12
|
'RUNNING',
|
package/dist/saga/store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/saga/store.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport {\n type SagaInstance,\n type SagaState,\n type SagaStepOutcome,\n type SagaStore,\n SagaStoreTag,\n} from '@entifix/transactions';\nimport { Effect, Layer } from 'effect';\nimport type { Db } from 'mongodb';\n\nimport { MongoDatabaseTag } from '../mongo-database/mongo-database.js';\n\n/**\n * Saga instances: one document per flow, the durable record a coordinator\n * resumes from (r10c ADR 0055).\n */\nexport const SAGA_COLLECTION = 'saga_instances';\n\n/** The two states a sweep treats as unfinished. */\nconst UNFINISHED: readonly SagaState[] = ['RUNNING', 'COMPENSATING'];\n\nconst failed = (message: string, sagaId: string) => (error: unknown) =>\n new EntifixConnError(message, error, { sagaId });\n\nconst sagaCollection = (db: Db) => db.collection<SagaInstance>(SAGA_COLLECTION);\n\nconst deadline = (olderThanMs: number) =>\n new Date(Date.now() - olderThanMs).toISOString();\n\n/**\n * A unique index on `sagaId`, so a resumed coordinator can never create a\n * second instance for one flow, and `{ state, updatedAt }` for the sweep —\n * without it every pass is a scan of every saga ever run, on a schedule.\n */\nexport const ensureSagaIndexes = (db: Db) =>\n Effect.tryPromise({\n try: async () => {\n await sagaCollection(db).createIndex({ sagaId: 1 }, { unique: true });\n await sagaCollection(db).createIndex({ state: 1, updatedAt: 1 });\n },\n catch: error =>\n new EntifixConnError('Failed to ensure saga indexes', error, {\n database: db.databaseName,\n }),\n });\n\n/** {@link SagaStore} over one Mongo database. */\nexport const makeMongoSagaStore = (db: Db): SagaStore => {\n const collection = sagaCollection(db);\n const stamp = () => new Date().toISOString();\n\n return {\n start: instance =>\n Effect.tryPromise({\n try: async () => {\n // `$setOnInsert` with an upsert rather than `insertOne`: a coordinator\n // restarted mid-flight re-enters with an id it already wrote, and\n // refusing that would turn a recoverable resume into a hard failure.\n await collection.updateOne(\n { sagaId: instance.sagaId },\n { $setOnInsert: { ...instance, updatedAt: stamp() } },\n { upsert: true },\n );\n },\n catch: failed('Failed to start the saga instance', instance.sagaId),\n }),\n\n beginStep: (sagaId, stepIndex) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n { $set: { stepIndex, updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to record the step transition', sagaId),\n }),\n\n recordOutcome: (sagaId, outcome: SagaStepOutcome) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n // `$push`, never a read-modify-write of the array: two outcomes\n // recorded from one process would otherwise lose one another, and a\n // lost outcome is a compensation that cannot address what it undoes.\n { $push: { outcomes: outcome }, $set: { updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to record the step outcome', sagaId),\n }),\n\n settle: (sagaId, state, error) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n { $set: { state, error, updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to settle the saga instance', sagaId),\n }),\n\n /**\n * Take ownership of a stale instance, or answer `undefined`.\n *\n * ⚠️ **One conditional write, not a read and then a write.** The filter\n * repeats `findStale`'s predicate, so two sweepers racing the same instance\n * produce one winner: the loser's filter no longer matches because the\n * winner re-stamped `updatedAt`. That stamp is also what stops the next tick\n * finding the instance this sweep is still working on.\n */\n claimForResume: (sagaId, olderThanMs) =>\n Effect.tryPromise({\n try: async () => {\n const claimed = await collection.findOneAndUpdate(\n {\n sagaId,\n state: { $in: [...UNFINISHED] },\n updatedAt: { $lt: deadline(olderThanMs) },\n },\n { $inc: { resumeAttempts: 1 }, $set: { updatedAt: stamp() } },\n { returnDocument: 'after', projection: { _id: 0 } },\n );\n return (claimed ?? undefined) as SagaInstance | undefined;\n },\n catch: failed('Failed to claim the saga instance', sagaId),\n }),\n\n /**\n * Flag one step's calls as given back, addressing the outcome by its step\n * id: outcomes are `$push`ed, so an index is a fact about arrival order and\n * a resumed walk must not depend on one.\n */\n markCompensated: (sagaId, stepId) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n {\n $set: {\n 'outcomes.$[entry].compensated': true,\n updatedAt: stamp(),\n },\n },\n { arrayFilters: [{ 'entry.stepId': stepId }] },\n );\n },\n catch: failed('Failed to mark the step compensated', sagaId),\n }),\n\n get: sagaId =>\n Effect.tryPromise({\n try: async () =>\n ((await collection.findOne({ sagaId }, { projection: { _id: 0 } })) ??\n undefined) as SagaInstance | undefined,\n catch: failed('Failed to read the saga instance', sagaId),\n }),\n\n /**\n * Instances stuck mid-flight past a deadline. ⚠️ `COMPENSATING` counts: a\n * coordinator that died while unwinding is the state that leaves holds in\n * place, and the one an operator most needs to find.\n */\n findStale: olderThanMs =>\n Effect.tryPromise({\n try: async () =>\n (await collection\n .find(\n {\n state: { $in: [...UNFINISHED] },\n updatedAt: { $lt: deadline(olderThanMs) },\n },\n { projection: { _id: 0 } },\n )\n .toArray()) as SagaInstance[],\n catch: failed('Failed to find stale saga instances', ''),\n }),\n };\n};\n\n/**\n * Provides {@link SagaStoreTag} from the service's own database, creating the\n * indexes first.\n */\nexport const MongoSagaStoreLayer = Layer.effect(\n SagaStoreTag,\n Effect.gen(function* () {\n const db = yield* MongoDatabaseTag;\n yield* ensureSagaIndexes(db);\n return makeMongoSagaStore(db);\n }),\n);\n"],"names":["EntifixConnError","SagaStoreTag","Effect","Layer","MongoDatabaseTag","SAGA_COLLECTION","UNFINISHED","failed","message","sagaId","error","sagaCollection","db","collection","deadline","olderThanMs","Date","now","toISOString","ensureSagaIndexes","tryPromise","try","createIndex","unique","state","updatedAt","catch","database","databaseName","makeMongoSagaStore","stamp","start","instance","updateOne","$setOnInsert","upsert","beginStep","stepIndex","$set","recordOutcome","outcome","$push","outcomes","settle","claimForResume","claimed","findOneAndUpdate","$in","$lt","$inc","resumeAttempts","returnDocument","projection","_id","undefined","markCompensated","stepId","arrayFilters","get","findOne","findStale","find","toArray","MongoSagaStoreLayer","effect","gen"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/saga/store.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport {\n type SagaInstance,\n type SagaState,\n type SagaStepOutcome,\n type SagaStore,\n SagaStoreTag,\n} from '@entifix/transactions';\nimport { Effect, Layer } from 'effect';\nimport type { Db } from 'mongodb';\n\nimport { MongoDatabaseTag } from '../mongo-database/mongo-database.js';\n\n/**\n * Saga instances: one document per flow, the durable record a coordinator\n * resumes from, so where to resume is derived from the outcomes rather than\n * stored beside them.\n */\nexport const SAGA_COLLECTION = 'saga_instances';\n\n/** The two states a sweep treats as unfinished. */\nconst UNFINISHED: readonly SagaState[] = ['RUNNING', 'COMPENSATING'];\n\nconst failed = (message: string, sagaId: string) => (error: unknown) =>\n new EntifixConnError(message, error, { sagaId });\n\nconst sagaCollection = (db: Db) => db.collection<SagaInstance>(SAGA_COLLECTION);\n\nconst deadline = (olderThanMs: number) =>\n new Date(Date.now() - olderThanMs).toISOString();\n\n/**\n * A unique index on `sagaId`, so a resumed coordinator can never create a\n * second instance for one flow, and `{ state, updatedAt }` for the sweep —\n * without it every pass is a scan of every saga ever run, on a schedule.\n */\nexport const ensureSagaIndexes = (db: Db) =>\n Effect.tryPromise({\n try: async () => {\n await sagaCollection(db).createIndex({ sagaId: 1 }, { unique: true });\n await sagaCollection(db).createIndex({ state: 1, updatedAt: 1 });\n },\n catch: error =>\n new EntifixConnError('Failed to ensure saga indexes', error, {\n database: db.databaseName,\n }),\n });\n\n/** {@link SagaStore} over one Mongo database. */\nexport const makeMongoSagaStore = (db: Db): SagaStore => {\n const collection = sagaCollection(db);\n const stamp = () => new Date().toISOString();\n\n return {\n start: instance =>\n Effect.tryPromise({\n try: async () => {\n // `$setOnInsert` with an upsert rather than `insertOne`: a coordinator\n // restarted mid-flight re-enters with an id it already wrote, and\n // refusing that would turn a recoverable resume into a hard failure.\n await collection.updateOne(\n { sagaId: instance.sagaId },\n { $setOnInsert: { ...instance, updatedAt: stamp() } },\n { upsert: true },\n );\n },\n catch: failed('Failed to start the saga instance', instance.sagaId),\n }),\n\n beginStep: (sagaId, stepIndex) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n { $set: { stepIndex, updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to record the step transition', sagaId),\n }),\n\n recordOutcome: (sagaId, outcome: SagaStepOutcome) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n // `$push`, never a read-modify-write of the array: two outcomes\n // recorded from one process would otherwise lose one another, and a\n // lost outcome is a compensation that cannot address what it undoes.\n { $push: { outcomes: outcome }, $set: { updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to record the step outcome', sagaId),\n }),\n\n settle: (sagaId, state, error) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n { $set: { state, error, updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to settle the saga instance', sagaId),\n }),\n\n /**\n * Take ownership of a stale instance, or answer `undefined`.\n *\n * ⚠️ **One conditional write, not a read and then a write.** The filter\n * repeats `findStale`'s predicate, so two sweepers racing the same instance\n * produce one winner: the loser's filter no longer matches because the\n * winner re-stamped `updatedAt`. That stamp is also what stops the next tick\n * finding the instance this sweep is still working on.\n */\n claimForResume: (sagaId, olderThanMs) =>\n Effect.tryPromise({\n try: async () => {\n const claimed = await collection.findOneAndUpdate(\n {\n sagaId,\n state: { $in: [...UNFINISHED] },\n updatedAt: { $lt: deadline(olderThanMs) },\n },\n { $inc: { resumeAttempts: 1 }, $set: { updatedAt: stamp() } },\n { returnDocument: 'after', projection: { _id: 0 } },\n );\n return (claimed ?? undefined) as SagaInstance | undefined;\n },\n catch: failed('Failed to claim the saga instance', sagaId),\n }),\n\n /**\n * Flag one step's calls as given back, addressing the outcome by its step\n * id: outcomes are `$push`ed, so an index is a fact about arrival order and\n * a resumed walk must not depend on one.\n */\n markCompensated: (sagaId, stepId) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n {\n $set: {\n 'outcomes.$[entry].compensated': true,\n updatedAt: stamp(),\n },\n },\n { arrayFilters: [{ 'entry.stepId': stepId }] },\n );\n },\n catch: failed('Failed to mark the step compensated', sagaId),\n }),\n\n get: sagaId =>\n Effect.tryPromise({\n try: async () =>\n ((await collection.findOne({ sagaId }, { projection: { _id: 0 } })) ??\n undefined) as SagaInstance | undefined,\n catch: failed('Failed to read the saga instance', sagaId),\n }),\n\n /**\n * Instances stuck mid-flight past a deadline. ⚠️ `COMPENSATING` counts: a\n * coordinator that died while unwinding is the state that leaves holds in\n * place, and the one an operator most needs to find.\n */\n findStale: olderThanMs =>\n Effect.tryPromise({\n try: async () =>\n (await collection\n .find(\n {\n state: { $in: [...UNFINISHED] },\n updatedAt: { $lt: deadline(olderThanMs) },\n },\n { projection: { _id: 0 } },\n )\n .toArray()) as SagaInstance[],\n catch: failed('Failed to find stale saga instances', ''),\n }),\n };\n};\n\n/**\n * Provides {@link SagaStoreTag} from the service's own database, creating the\n * indexes first.\n */\nexport const MongoSagaStoreLayer = Layer.effect(\n SagaStoreTag,\n Effect.gen(function* () {\n const db = yield* MongoDatabaseTag;\n yield* ensureSagaIndexes(db);\n return makeMongoSagaStore(db);\n }),\n);\n"],"names":["EntifixConnError","SagaStoreTag","Effect","Layer","MongoDatabaseTag","SAGA_COLLECTION","UNFINISHED","failed","message","sagaId","error","sagaCollection","db","collection","deadline","olderThanMs","Date","now","toISOString","ensureSagaIndexes","tryPromise","try","createIndex","unique","state","updatedAt","catch","database","databaseName","makeMongoSagaStore","stamp","start","instance","updateOne","$setOnInsert","upsert","beginStep","stepIndex","$set","recordOutcome","outcome","$push","outcomes","settle","claimForResume","claimed","findOneAndUpdate","$in","$lt","$inc","resumeAttempts","returnDocument","projection","_id","undefined","markCompensated","stepId","arrayFilters","get","findOne","findStale","find","toArray","MongoSagaStoreLayer","effect","gen"],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The half of this package that needs `@entifix/transactions`: the
|
|
3
|
-
* transactional outbox
|
|
3
|
+
* transactional outbox written inside the entity's own Mongo
|
|
4
4
|
* transaction, and the inbox that makes a redelivered command idempotent.
|
|
5
5
|
*
|
|
6
6
|
* Behind a subpath so entity CRUD over Mongo does not arrive with the saga
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The half of this package that needs `@entifix/transactions`: the
|
|
3
|
-
* transactional outbox
|
|
3
|
+
* transactional outbox written inside the entity's own Mongo
|
|
4
4
|
* transaction, and the inbox that makes a redelivered command idempotent.
|
|
5
5
|
*
|
|
6
6
|
* Behind a subpath so entity CRUD over Mongo does not arrive with the saga
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/transactions/index.ts"],"sourcesContent":["/**\n * The half of this package that needs `@entifix/transactions`: the\n * transactional outbox
|
|
1
|
+
{"version":3,"sources":["../../src/transactions/index.ts"],"sourcesContent":["/**\n * The half of this package that needs `@entifix/transactions`: the\n * transactional outbox written inside the entity's own Mongo\n * transaction, and the inbox that makes a redelivered command idempotent.\n *\n * Behind a subpath so entity CRUD over Mongo does not arrive with the saga\n * engine attached. Package-level dependencies are not per-subpath, so what\n * actually keeps it optional is the `peerDependenciesMeta.optional` entry in\n * the manifest — importing this module without that peer installed is the\n * error, and it is the adopter's to make deliberately.\n */\nexport * from '../inbox/store.js';\nexport * from '../outbox/metrics.js';\nexport * from '../outbox/relay.js';\nexport * from '../outbox/store.js';\nexport * from '../saga/store.js';\n"],"names":[],"mappings":"AAAA;;;;;;;;;;CAUC,GACD,cAAc,oBAAoB;AAClC,cAAc,uBAAuB;AACrC,cAAc,qBAAqB;AACnC,cAAc,qBAAqB;AACnC,cAAc,mBAAmB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/mongo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "MongoDB adapter: an entity repository, RSQL-to-Mongo query translation, per-tenant databases, and an optional transactional outbox.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -57,17 +57,17 @@
|
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@swc/helpers": "0.5.23",
|
|
59
59
|
"mongodb": "7.7.0",
|
|
60
|
-
"@entifix/
|
|
61
|
-
"@entifix/
|
|
60
|
+
"@entifix/business": "0.6.0",
|
|
61
|
+
"@entifix/core": "0.6.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"effect": "4.0.5",
|
|
65
|
-
"@entifix/testing-unit": "0.
|
|
66
|
-
"@entifix/transactions": "0.
|
|
65
|
+
"@entifix/testing-unit": "0.6.0",
|
|
66
|
+
"@entifix/transactions": "0.6.0"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"effect": "^4.0.5",
|
|
70
|
-
"@entifix/transactions": "0.
|
|
70
|
+
"@entifix/transactions": "0.6.0"
|
|
71
71
|
},
|
|
72
72
|
"peerDependenciesMeta": {
|
|
73
73
|
"@entifix/transactions": {
|