@entifix/mongo 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/adapters/make-mongo-facet-counter.d.ts.map +1 -1
  2. package/dist/adapters/make-mongo-facet-counter.js +3 -4
  3. package/dist/adapters/make-mongo-facet-counter.js.map +1 -1
  4. package/dist/adapters/make-mongo-link-resolver.d.ts.map +1 -1
  5. package/dist/adapters/make-mongo-link-resolver.js +2 -4
  6. package/dist/adapters/make-mongo-link-resolver.js.map +1 -1
  7. package/dist/adapters/make-mongo-repository.d.ts +3 -3
  8. package/dist/adapters/make-mongo-repository.d.ts.map +1 -1
  9. package/dist/adapters/make-mongo-repository.js +11 -18
  10. package/dist/adapters/make-mongo-repository.js.map +1 -1
  11. package/dist/adapters/mongo-facet-counter-factory.d.ts.map +1 -1
  12. package/dist/adapters/mongo-facet-counter-factory.js +2 -4
  13. package/dist/adapters/mongo-facet-counter-factory.js.map +1 -1
  14. package/dist/adapters/mongo-repository-factory.d.ts.map +1 -1
  15. package/dist/adapters/mongo-repository-factory.js +2 -4
  16. package/dist/adapters/mongo-repository-factory.js.map +1 -1
  17. package/dist/errors/translate-mongo-error.d.ts +30 -0
  18. package/dist/errors/translate-mongo-error.d.ts.map +1 -0
  19. package/dist/errors/translate-mongo-error.js +74 -0
  20. package/dist/errors/translate-mongo-error.js.map +1 -0
  21. package/dist/inbox/store.d.ts +1 -2
  22. package/dist/inbox/store.d.ts.map +1 -1
  23. package/dist/inbox/store.js +3 -8
  24. package/dist/inbox/store.js.map +1 -1
  25. package/dist/mongo-database/mongo-database.d.ts +3 -3
  26. package/dist/mongo-database/mongo-database.d.ts.map +1 -1
  27. package/dist/mongo-database/mongo-database.js +2 -2
  28. package/dist/mongo-database/mongo-database.js.map +1 -1
  29. package/dist/mongo-database/mongo-tenant-directory.d.ts.map +1 -1
  30. package/dist/mongo-database/mongo-tenant-directory.js +2 -4
  31. package/dist/mongo-database/mongo-tenant-directory.js.map +1 -1
  32. package/dist/mongo-database/mongo-tenant-resolver.d.ts.map +1 -1
  33. package/dist/mongo-database/mongo-tenant-resolver.js +18 -7
  34. package/dist/mongo-database/mongo-tenant-resolver.js.map +1 -1
  35. package/dist/outbox/relay.d.ts +50 -9
  36. package/dist/outbox/relay.d.ts.map +1 -1
  37. package/dist/outbox/relay.js +67 -26
  38. package/dist/outbox/relay.js.map +1 -1
  39. package/dist/outbox/store.d.ts +3 -3
  40. package/dist/outbox/store.d.ts.map +1 -1
  41. package/dist/outbox/store.js +8 -22
  42. package/dist/outbox/store.js.map +1 -1
  43. package/dist/saga/store.d.ts +3 -4
  44. package/dist/saga/store.d.ts.map +1 -1
  45. package/dist/saga/store.js +10 -15
  46. package/dist/saga/store.js.map +1 -1
  47. package/dist/unit-of-work/command-inbox.d.ts +2 -3
  48. package/dist/unit-of-work/command-inbox.d.ts.map +1 -1
  49. package/dist/unit-of-work/command-inbox.js +3 -7
  50. package/dist/unit-of-work/command-inbox.js.map +1 -1
  51. package/dist/unit-of-work/mongo-unit-of-work.d.ts.map +1 -1
  52. package/dist/unit-of-work/mongo-unit-of-work.js +30 -26
  53. package/dist/unit-of-work/mongo-unit-of-work.js.map +1 -1
  54. package/package.json +8 -8
@@ -1 +1 @@
1
- {"version":3,"file":"mongo-database.d.ts","sourceRoot":"","sources":["../../src/mongo-database/mongo-database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,OAAO,EAAU,KAAK,EAAY,MAAM,QAAQ,CAAC;AAC1D,OAAO,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;;AAE1C,6DAA6D;AAC7D,qBAAa,gBAAiB,SAAQ,qBAErC;CAAG;;AAEJ;;;;;;;;;GASG;AACH,qBAAa,cAAe,SAAQ,mBAGf;CAAG;AAExB,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,qBAAsB,SAAQ,mBAAmB;IAChE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAC3B,UAAU,mBAAmB,KAC5B,KAAK,CAAC,KAAK,CAAC,cAAc,EAAE,gBAAgB,CA2B5C,CAAC;AAEJ;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,GAC7B,UAAU,qBAAqB,KAC9B,KAAK,CAAC,KAAK,CAAC,gBAAgB,GAAG,cAAc,EAAE,gBAAgB,CAO/D,CAAC"}
1
+ {"version":3,"file":"mongo-database.d.ts","sourceRoot":"","sources":["../../src/mongo-database/mongo-database.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,EAAE,OAAO,EAAU,KAAK,EAAY,MAAM,QAAQ,CAAC;AAC1D,OAAO,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;;AAI1C,6DAA6D;AAC7D,qBAAa,gBAAiB,SAAQ,qBAErC;CAAG;;AAEJ;;;;;;;;;GASG;AACH,qBAAa,cAAe,SAAQ,mBAGf;CAAG;AAExB,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,qBAAsB,SAAQ,mBAAmB;IAChE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAC3B,UAAU,mBAAmB,KAC5B,KAAK,CAAC,KAAK,CAAC,cAAc,EAAE,cAAc,CA0B1C,CAAC;AAEJ;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,GAC7B,UAAU,qBAAqB,KAC9B,KAAK,CAAC,KAAK,CAAC,gBAAgB,GAAG,cAAc,EAAE,cAAc,CAO7D,CAAC"}
@@ -1,6 +1,6 @@
1
- import { EntifixConnError } from '@entifix/core';
2
1
  import { Context, Effect, Layer, Schedule } from 'effect';
3
2
  import { MongoClient } from 'mongodb';
3
+ import { translateMongoError } from '../errors/translate-mongo-error.js';
4
4
  /** DI tag carrying a connected mongodb {@link Db} handle. */ export class MongoDatabaseTag extends Context.Service()('MongoDatabaseTag') {
5
5
  }
6
6
  /**
@@ -35,7 +35,7 @@ import { MongoClient } from 'mongodb';
35
35
  // alive behind it. Fail fast instead.
36
36
  serverSelectionTimeoutMS: 5000
37
37
  }),
38
- catch: (error)=>new EntifixConnError('Failed to connect to MongoDB', error)
38
+ catch: (error)=>translateMongoError(error)
39
39
  }).pipe(// Boot order is not guaranteed — a service may start while Mongo is
40
40
  // still rolling out. Keep trying for a window instead of dying on the
41
41
  // first refusal, which leaves a dead process nothing will restart.
@@ -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 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"}
1
+ {"version":3,"sources":["../../src/mongo-database/mongo-database.ts"],"sourcesContent":["import type { EntifixFailure } from '@entifix/core';\nimport { Context, Effect, Layer, Schedule } from 'effect';\nimport { Db, MongoClient } from 'mongodb';\n\nimport { translateMongoError } from '../errors/translate-mongo-error.js';\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, EntifixFailure> =>\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 => translateMongoError(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, EntifixFailure> =>\n Layer.provideMerge(\n Layer.effect(\n MongoDatabaseTag,\n Effect.map(MongoClientTag, client => client.db(settings.dbName)),\n ),\n MongoClientLayer(settings),\n );\n"],"names":["Context","Effect","Layer","Schedule","MongoClient","translateMongoError","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":"AACA,SAASA,OAAO,EAAEC,MAAM,EAAEC,KAAK,EAAEC,QAAQ,QAAQ,SAAS;AAC1D,SAAaC,WAAW,QAAQ,UAAU;AAE1C,SAASC,mBAAmB,QAAQ,qCAAqC;AAEzE,2DAA2D,GAC3D,OAAO,MAAMC,yBAAyBN,QAAQO,OAAO,GACnD;AACC;AAEH;;;;;;;;;CASC,GACD,OAAO,MAAMC,uBAAuBR,QAAQO,OAAO,GAG/C;AAAmB;AAUvB;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAME,mBAAmB,CAC9BC,WAEAR,MAAMS,MAAM,CACVH,gBACAP,OAAOW,cAAc,CACnBX,OAAOY,UAAU,CAAC;QAChBC,KAAK,IACHV,YAAYW,OAAO,CAACL,SAASM,GAAG,EAAE;gBAChC,4DAA4D;gBAC5D,iEAAiE;gBACjE,iEAAiE;gBACjE,sCAAsC;gBACtCC,0BAA0B;YAC5B;QACFC,OAAOC,CAAAA,QAASd,oBAAoBc;IACtC,GAAGC,IAAI,CACL,oEAAoE;IACpE,sEAAsE;IACtE,mEAAmE;IACnEnB,OAAOoB,KAAK,CACVlB,SAASmB,WAAW,CAAC,cAAcF,IAAI,CACrCjB,SAASoB,IAAI,CAAC;QAAEC,UAAU;IAAa,OAI7CC,CAAAA,SAAUxB,OAAOyB,OAAO,CAAC,IAAMD,OAAOE,KAAK,MAE7C;AAEJ;;;;;;;CAOC,GACD,OAAO,MAAMC,qBAAqB,CAChClB,WAEAR,MAAM2B,YAAY,CAChB3B,MAAMS,MAAM,CACVL,kBACAL,OAAO6B,GAAG,CAACtB,gBAAgBiB,CAAAA,SAAUA,OAAOM,EAAE,CAACrB,SAASsB,MAAM,KAEhEvB,iBAAiBC,WACjB"}
@@ -1 +1 @@
1
- {"version":3,"file":"mongo-tenant-directory.d.ts","sourceRoot":"","sources":["../../src/mongo-database/mongo-tenant-directory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAGzD,OAAO,KAAK,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE/C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,wBAAwB,GACnC,QAAQ,WAAW,EACnB,QAAQ,MAAM,KACb,eAAe,CAAC,EAAE,CAuBnB,CAAC"}
1
+ {"version":3,"file":"mongo-tenant-directory.d.ts","sourceRoot":"","sources":["../../src/mongo-database/mongo-tenant-directory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEzD,OAAO,KAAK,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAI/C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,wBAAwB,GACnC,QAAQ,WAAW,EACnB,QAAQ,MAAM,KACb,eAAe,CAAC,EAAE,CAoBnB,CAAC"}
@@ -1,5 +1,5 @@
1
- import { EntifixConnError } from '@entifix/core';
2
1
  import { Effect } from 'effect';
2
+ import { translateMongoError } from '../errors/translate-mongo-error.js';
3
3
  /**
4
4
  * Lists every database named `<prefix><organizationId>` on one client — the
5
5
  * same naming {@link makeMongoTenantResolver} resolves one organization by.
@@ -22,9 +22,7 @@ import { Effect } from 'effect';
22
22
  handle: client.db(name)
23
23
  }));
24
24
  },
25
- catch: (error)=>new EntifixConnError('Failed to list tenant databases', error, {
26
- prefix
27
- })
25
+ catch: (error)=>translateMongoError(error)
28
26
  })
29
27
  });
30
28
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/mongo-database/mongo-tenant-directory.ts"],"sourcesContent":["import type { TenantDirectory } from '@entifix/business';\nimport { EntifixConnError } from '@entifix/core';\nimport { Effect } from 'effect';\nimport type { Db, MongoClient } from 'mongodb';\n\n/**\n * Lists every database named `<prefix><organizationId>` on one client — the\n * same naming {@link makeMongoTenantResolver} resolves one organization by.\n *\n * `listDatabases` needs cluster-wide rights and a round trip, which is why it\n * is a directory for background work and never a request's path: a request\n * already knows its organization and resolves it directly.\n *\n * Only a database that exists is listed, and Mongo creates one lazily on its\n * first write. An organization that has never written to this store is not\n * here, which is right for every sweep: it has nothing to sweep.\n */\nexport const makeMongoTenantDirectory = (\n client: MongoClient,\n prefix: string,\n): TenantDirectory<Db> => ({\n list: () =>\n Effect.tryPromise({\n try: async () => {\n const { databases } = await client.db('admin').admin().listDatabases({\n nameOnly: true,\n });\n return databases\n .map(database => database.name)\n .filter(\n name => name.startsWith(prefix) && name.length > prefix.length,\n )\n .sort()\n .map(name => ({\n organizationId: name.slice(prefix.length),\n handle: client.db(name),\n }));\n },\n catch: error =>\n new EntifixConnError('Failed to list tenant databases', error, {\n prefix,\n }),\n }),\n});\n"],"names":["EntifixConnError","Effect","makeMongoTenantDirectory","client","prefix","list","tryPromise","try","databases","db","admin","listDatabases","nameOnly","map","database","name","filter","startsWith","length","sort","organizationId","slice","handle","catch","error"],"mappings":"AACA,SAASA,gBAAgB,QAAQ,gBAAgB;AACjD,SAASC,MAAM,QAAQ,SAAS;AAGhC;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,2BAA2B,CACtCC,QACAC,SACyB,CAAA;QACzBC,MAAM,IACJJ,OAAOK,UAAU,CAAC;gBAChBC,KAAK;oBACH,MAAM,EAAEC,SAAS,EAAE,GAAG,MAAML,OAAOM,EAAE,CAAC,SAASC,KAAK,GAAGC,aAAa,CAAC;wBACnEC,UAAU;oBACZ;oBACA,OAAOJ,UACJK,GAAG,CAACC,CAAAA,WAAYA,SAASC,IAAI,EAC7BC,MAAM,CACLD,CAAAA,OAAQA,KAAKE,UAAU,CAACb,WAAWW,KAAKG,MAAM,GAAGd,OAAOc,MAAM,EAE/DC,IAAI,GACJN,GAAG,CAACE,CAAAA,OAAS,CAAA;4BACZK,gBAAgBL,KAAKM,KAAK,CAACjB,OAAOc,MAAM;4BACxCI,QAAQnB,OAAOM,EAAE,CAACM;wBACpB,CAAA;gBACJ;gBACAQ,OAAOC,CAAAA,QACL,IAAIxB,iBAAiB,mCAAmCwB,OAAO;wBAC7DpB;oBACF;YACJ;IACJ,CAAA,EAAG"}
1
+ {"version":3,"sources":["../../src/mongo-database/mongo-tenant-directory.ts"],"sourcesContent":["import type { TenantDirectory } from '@entifix/business';\nimport { Effect } from 'effect';\nimport type { Db, MongoClient } from 'mongodb';\n\nimport { translateMongoError } from '../errors/translate-mongo-error.js';\n\n/**\n * Lists every database named `<prefix><organizationId>` on one client — the\n * same naming {@link makeMongoTenantResolver} resolves one organization by.\n *\n * `listDatabases` needs cluster-wide rights and a round trip, which is why it\n * is a directory for background work and never a request's path: a request\n * already knows its organization and resolves it directly.\n *\n * Only a database that exists is listed, and Mongo creates one lazily on its\n * first write. An organization that has never written to this store is not\n * here, which is right for every sweep: it has nothing to sweep.\n */\nexport const makeMongoTenantDirectory = (\n client: MongoClient,\n prefix: string,\n): TenantDirectory<Db> => ({\n list: () =>\n Effect.tryPromise({\n try: async () => {\n const { databases } = await client.db('admin').admin().listDatabases({\n nameOnly: true,\n });\n return databases\n .map(database => database.name)\n .filter(\n name => name.startsWith(prefix) && name.length > prefix.length,\n )\n .sort()\n .map(name => ({\n organizationId: name.slice(prefix.length),\n handle: client.db(name),\n }));\n },\n catch: error => translateMongoError(error),\n }),\n});\n"],"names":["Effect","translateMongoError","makeMongoTenantDirectory","client","prefix","list","tryPromise","try","databases","db","admin","listDatabases","nameOnly","map","database","name","filter","startsWith","length","sort","organizationId","slice","handle","catch","error"],"mappings":"AACA,SAASA,MAAM,QAAQ,SAAS;AAGhC,SAASC,mBAAmB,QAAQ,qCAAqC;AAEzE;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,2BAA2B,CACtCC,QACAC,SACyB,CAAA;QACzBC,MAAM,IACJL,OAAOM,UAAU,CAAC;gBAChBC,KAAK;oBACH,MAAM,EAAEC,SAAS,EAAE,GAAG,MAAML,OAAOM,EAAE,CAAC,SAASC,KAAK,GAAGC,aAAa,CAAC;wBACnEC,UAAU;oBACZ;oBACA,OAAOJ,UACJK,GAAG,CAACC,CAAAA,WAAYA,SAASC,IAAI,EAC7BC,MAAM,CACLD,CAAAA,OAAQA,KAAKE,UAAU,CAACb,WAAWW,KAAKG,MAAM,GAAGd,OAAOc,MAAM,EAE/DC,IAAI,GACJN,GAAG,CAACE,CAAAA,OAAS,CAAA;4BACZK,gBAAgBL,KAAKM,KAAK,CAACjB,OAAOc,MAAM;4BACxCI,QAAQnB,OAAOM,EAAE,CAACM;wBACpB,CAAA;gBACJ;gBACAQ,OAAOC,CAAAA,QAASvB,oBAAoBuB;YACtC;IACJ,CAAA,EAAG"}
@@ -1 +1 @@
1
- {"version":3,"file":"mongo-tenant-resolver.d.ts","sourceRoot":"","sources":["../../src/mongo-database/mongo-tenant-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAGhE,OAAO,KAAK,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAa/C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,GAClC,QAAQ,WAAW,EACnB,QAAQ,MAAM,KACb,sBAAsB,CAAC,EAAE,CA2B1B,CAAC"}
1
+ {"version":3,"file":"mongo-tenant-resolver.d.ts","sourceRoot":"","sources":["../../src/mongo-database/mongo-tenant-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAGhE,OAAO,KAAK,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAa/C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,GAClC,QAAQ,WAAW,EACnB,QAAQ,MAAM,KACb,sBAAsB,CAAC,EAAE,CAmC1B,CAAC"}
@@ -1,4 +1,4 @@
1
- import { EntifixConnError } from '@entifix/core';
1
+ import { Invalid } from '@entifix/core';
2
2
  import { Effect } from 'effect';
3
3
  /** Mongo's own limit on a database name, minus room for a prefix. */ const MAX_DB_NAME_LENGTH = 63;
4
4
  /**
@@ -21,16 +21,27 @@ import { Effect } from 'effect';
21
21
  */ export const makeMongoTenantResolver = (client, prefix)=>({
22
22
  forOrganization: (organizationId)=>Effect.gen(function*() {
23
23
  if (!SAFE_ORGANIZATION_ID.test(organizationId)) {
24
- return yield* Effect.fail(new EntifixConnError('Refusing to resolve a tenant database from an unsafe organization id', undefined, // The id itself is deliberately not logged: it arrived unvalidated.
25
- {
26
- prefix
24
+ return yield* Effect.fail(new Invalid({
25
+ errors: [
26
+ {
27
+ pointer: '/organizationId',
28
+ // The id itself is deliberately not echoed: it arrived unvalidated.
29
+ detail: 'not a safe organization id',
30
+ code: 'unsafeOrganizationId'
31
+ }
32
+ ]
27
33
  }));
28
34
  }
29
35
  const name = `${prefix}${organizationId}`;
30
36
  if (name.length > MAX_DB_NAME_LENGTH) {
31
- return yield* Effect.fail(new EntifixConnError('Tenant database name exceeds the Mongo limit', undefined, {
32
- prefix,
33
- length: name.length
37
+ return yield* Effect.fail(new Invalid({
38
+ errors: [
39
+ {
40
+ pointer: '/organizationId',
41
+ detail: `the tenant database name exceeds ${MAX_DB_NAME_LENGTH} characters`,
42
+ code: 'organizationIdTooLong'
43
+ }
44
+ ]
34
45
  }));
35
46
  }
36
47
  return client.db(name);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/mongo-database/mongo-tenant-resolver.ts"],"sourcesContent":["import type { TenantDatabaseResolver } from '@entifix/business';\nimport { EntifixConnError } from '@entifix/core';\nimport { Effect } from 'effect';\nimport type { Db, MongoClient } from 'mongodb';\n\n/** Mongo's own limit on a database name, minus room for a prefix. */\nconst MAX_DB_NAME_LENGTH = 63;\n\n/**\n * Mongo names a database with a small character set. An organization id is a\n * hex ObjectId today, but this guards the shape rather than trusting it: a name\n * assembled from an unvalidated id is a place where a crafted value could reach\n * another tenant's database.\n */\nconst SAFE_ORGANIZATION_ID = /^[a-zA-Z0-9_-]+$/;\n\n/**\n * Resolves one organization's Mongo database from a single connected client.\n *\n * Cheap by construction: `client.db(name)` returns a **handle**, not a\n * connection, so N organizations share one pool and one socket set, and Mongo\n * creates the database lazily on first write — which is why provisioning an\n * organization is a control-plane record plus this naming convention, with no\n * `CREATE DATABASE` step to fail halfway.\n *\n * Call it inside the request. The pool is the boot-time `MongoDatabaseLayer`;\n * wrapping this in a per-request `Layer` would rebuild the pool per request.\n */\nexport const makeMongoTenantResolver = (\n client: MongoClient,\n prefix: string,\n): TenantDatabaseResolver<Db> => ({\n forOrganization: (organizationId: string) =>\n Effect.gen(function* () {\n if (!SAFE_ORGANIZATION_ID.test(organizationId)) {\n return yield* Effect.fail(\n new EntifixConnError(\n 'Refusing to resolve a tenant database from an unsafe organization id',\n undefined,\n // The id itself is deliberately not logged: it arrived unvalidated.\n { prefix },\n ),\n );\n }\n\n const name = `${prefix}${organizationId}`;\n if (name.length > MAX_DB_NAME_LENGTH) {\n return yield* Effect.fail(\n new EntifixConnError(\n 'Tenant database name exceeds the Mongo limit',\n undefined,\n { prefix, length: name.length },\n ),\n );\n }\n\n return client.db(name);\n }),\n});\n"],"names":["EntifixConnError","Effect","MAX_DB_NAME_LENGTH","SAFE_ORGANIZATION_ID","makeMongoTenantResolver","client","prefix","forOrganization","organizationId","gen","test","fail","undefined","name","length","db"],"mappings":"AACA,SAASA,gBAAgB,QAAQ,gBAAgB;AACjD,SAASC,MAAM,QAAQ,SAAS;AAGhC,mEAAmE,GACnE,MAAMC,qBAAqB;AAE3B;;;;;CAKC,GACD,MAAMC,uBAAuB;AAE7B;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,QACAC,SACgC,CAAA;QAChCC,iBAAiB,CAACC,iBAChBP,OAAOQ,GAAG,CAAC;gBACT,IAAI,CAACN,qBAAqBO,IAAI,CAACF,iBAAiB;oBAC9C,OAAO,OAAOP,OAAOU,IAAI,CACvB,IAAIX,iBACF,wEACAY,WACA,oEAAoE;oBACpE;wBAAEN;oBAAO;gBAGf;gBAEA,MAAMO,OAAO,GAAGP,SAASE,gBAAgB;gBACzC,IAAIK,KAAKC,MAAM,GAAGZ,oBAAoB;oBACpC,OAAO,OAAOD,OAAOU,IAAI,CACvB,IAAIX,iBACF,gDACAY,WACA;wBAAEN;wBAAQQ,QAAQD,KAAKC,MAAM;oBAAC;gBAGpC;gBAEA,OAAOT,OAAOU,EAAE,CAACF;YACnB;IACJ,CAAA,EAAG"}
1
+ {"version":3,"sources":["../../src/mongo-database/mongo-tenant-resolver.ts"],"sourcesContent":["import type { TenantDatabaseResolver } from '@entifix/business';\nimport { Invalid } from '@entifix/core';\nimport { Effect } from 'effect';\nimport type { Db, MongoClient } from 'mongodb';\n\n/** Mongo's own limit on a database name, minus room for a prefix. */\nconst MAX_DB_NAME_LENGTH = 63;\n\n/**\n * Mongo names a database with a small character set. An organization id is a\n * hex ObjectId today, but this guards the shape rather than trusting it: a name\n * assembled from an unvalidated id is a place where a crafted value could reach\n * another tenant's database.\n */\nconst SAFE_ORGANIZATION_ID = /^[a-zA-Z0-9_-]+$/;\n\n/**\n * Resolves one organization's Mongo database from a single connected client.\n *\n * Cheap by construction: `client.db(name)` returns a **handle**, not a\n * connection, so N organizations share one pool and one socket set, and Mongo\n * creates the database lazily on first write — which is why provisioning an\n * organization is a control-plane record plus this naming convention, with no\n * `CREATE DATABASE` step to fail halfway.\n *\n * Call it inside the request. The pool is the boot-time `MongoDatabaseLayer`;\n * wrapping this in a per-request `Layer` would rebuild the pool per request.\n */\nexport const makeMongoTenantResolver = (\n client: MongoClient,\n prefix: string,\n): TenantDatabaseResolver<Db> => ({\n forOrganization: (organizationId: string) =>\n Effect.gen(function* () {\n if (!SAFE_ORGANIZATION_ID.test(organizationId)) {\n return yield* Effect.fail(\n new Invalid({\n errors: [\n {\n pointer: '/organizationId',\n // The id itself is deliberately not echoed: it arrived unvalidated.\n detail: 'not a safe organization id',\n code: 'unsafeOrganizationId',\n },\n ],\n }),\n );\n }\n\n const name = `${prefix}${organizationId}`;\n if (name.length > MAX_DB_NAME_LENGTH) {\n return yield* Effect.fail(\n new Invalid({\n errors: [\n {\n pointer: '/organizationId',\n detail: `the tenant database name exceeds ${MAX_DB_NAME_LENGTH} characters`,\n code: 'organizationIdTooLong',\n },\n ],\n }),\n );\n }\n\n return client.db(name);\n }),\n});\n"],"names":["Invalid","Effect","MAX_DB_NAME_LENGTH","SAFE_ORGANIZATION_ID","makeMongoTenantResolver","client","prefix","forOrganization","organizationId","gen","test","fail","errors","pointer","detail","code","name","length","db"],"mappings":"AACA,SAASA,OAAO,QAAQ,gBAAgB;AACxC,SAASC,MAAM,QAAQ,SAAS;AAGhC,mEAAmE,GACnE,MAAMC,qBAAqB;AAE3B;;;;;CAKC,GACD,MAAMC,uBAAuB;AAE7B;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,QACAC,SACgC,CAAA;QAChCC,iBAAiB,CAACC,iBAChBP,OAAOQ,GAAG,CAAC;gBACT,IAAI,CAACN,qBAAqBO,IAAI,CAACF,iBAAiB;oBAC9C,OAAO,OAAOP,OAAOU,IAAI,CACvB,IAAIX,QAAQ;wBACVY,QAAQ;4BACN;gCACEC,SAAS;gCACT,oEAAoE;gCACpEC,QAAQ;gCACRC,MAAM;4BACR;yBACD;oBACH;gBAEJ;gBAEA,MAAMC,OAAO,GAAGV,SAASE,gBAAgB;gBACzC,IAAIQ,KAAKC,MAAM,GAAGf,oBAAoB;oBACpC,OAAO,OAAOD,OAAOU,IAAI,CACvB,IAAIX,QAAQ;wBACVY,QAAQ;4BACN;gCACEC,SAAS;gCACTC,QAAQ,CAAC,iCAAiC,EAAEZ,mBAAmB,WAAW,CAAC;gCAC3Ea,MAAM;4BACR;yBACD;oBACH;gBAEJ;gBAEA,OAAOV,OAAOa,EAAE,CAACF;YACnB;IACJ,CAAA,EAAG"}
@@ -3,10 +3,46 @@ import { type EventBus, EventBusTag, type OutboxStats, type TransactionOutbox }
3
3
  import { Context, Duration, Effect } from 'effect';
4
4
  import type { Db } from 'mongodb';
5
5
  import { MongoDatabaseTag } from '../mongo-database/mongo-database.js';
6
- /** How often the sweep looks for entries the fast path did not carry. */
7
6
  export declare const SWEEP_INTERVAL: Duration.Duration;
8
7
  /** Entries drained per pass, per database. */
9
8
  export declare const BATCH = 100;
9
+ /**
10
+ * What a relay pass did, for the host to log
11
+ * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)).
12
+ *
13
+ * The relay decides nothing about level or sink: it says what happened and the
14
+ * fork site that started it — the slice's shell — decides how loud that is. A
15
+ * `failure` is whatever the store or the bus failed with; a typed one carries
16
+ * its code and category for the log line.
17
+ */
18
+ export type RelayOutcome = {
19
+ readonly _tag: 'drained';
20
+ readonly database: string;
21
+ readonly sent: number;
22
+ } | {
23
+ readonly _tag: 'quarantined';
24
+ readonly database: string;
25
+ readonly eventId: string;
26
+ readonly eventName: string;
27
+ readonly attempts: number;
28
+ readonly code: string;
29
+ readonly reason: string;
30
+ } | {
31
+ readonly _tag: 'sweepFailed';
32
+ readonly database?: string;
33
+ readonly failure: unknown;
34
+ } | {
35
+ readonly _tag: 'drainFailed';
36
+ readonly database: string;
37
+ readonly failure: unknown;
38
+ };
39
+ /** Where a relay's outcomes go. The host's fork site supplies it. */
40
+ export type RelayOutcomeSink = (outcome: RelayOutcome) => Effect.Effect<void>;
41
+ /**
42
+ * Hears nothing. The default only so a spec can start a relay without one —
43
+ * a host that passes no sink has a relay nobody can watch.
44
+ */
45
+ export declare const ignoreRelayOutcome: RelayOutcomeSink;
10
46
  declare const OutboxMaxAttempts_base: Context.ServiceClass<OutboxMaxAttempts, "OutboxMaxAttempts", number>;
11
47
  /**
12
48
  * Publish attempts an outbox entry gets before it is quarantined, from
@@ -29,6 +65,8 @@ export interface DrainOptions {
29
65
  readonly maxAttempts: number;
30
66
  /** The database being drained; carried so a quarantine log names it. */
31
67
  readonly database: string;
68
+ /** Where the pass reports what it did; see {@link RelayOutcome}. */
69
+ readonly onOutcome?: RelayOutcomeSink;
32
70
  }
33
71
  /**
34
72
  * Publish one batch of pending entries, oldest first, and answer how many went.
@@ -42,7 +80,7 @@ export interface DrainOptions {
42
80
  * skip-so-the-head-moves: an entry that can never publish must stop blocking
43
81
  * every entry behind it forever.
44
82
  */
45
- export declare const drainOutbox: (outbox: TransactionOutbox, bus: EventBus, options: DrainOptions) => Effect.Effect<number, import("@entifix/core").EntifixConnError, never>;
83
+ export declare const drainOutbox: (outbox: TransactionOutbox, bus: EventBus, options: DrainOptions) => Effect.Effect<number, import("@entifix/core").StoreFailure, never>;
46
84
  /**
47
85
  * Sample a sweep's depth and age.
48
86
  *
@@ -62,9 +100,9 @@ export type OutboxStatsSink = (database: string, stats: OutboxStats) => Effect.E
62
100
  * Stats are sampled **after** the drain, so the gauge reports what is still
63
101
  * waiting rather than what was waiting when the pass began.
64
102
  *
65
- * Every failure is caught and logged. This runs inside `Effect.forever`, and an
66
- * error that escapes ends the daemon silently — a relay that stopped is
67
- * indistinguishable from a relay with nothing to do.
103
+ * Every failure is caught and reported as `sweepFailed`. This runs inside
104
+ * `Effect.forever`, and an error that escapes ends the daemon silently — a
105
+ * relay that stopped is indistinguishable from a relay with nothing to do.
68
106
  */
69
107
  export declare const sweepOutbox: (outbox: TransactionOutbox, bus: EventBus, options: DrainOptions & {
70
108
  readonly onStats?: OutboxStatsSink;
@@ -85,7 +123,8 @@ export declare const sweepOutbox: (outbox: TransactionOutbox, bus: EventBus, opt
85
123
  */
86
124
  export declare const startOutboxRelay: (options?: {
87
125
  readonly onStats?: OutboxStatsSink;
88
- }) => Effect.Effect<void, import("@entifix/core").EntifixConnError, ShutdownRegistryTag | MongoDatabaseTag | EventBusTag | OutboxMaxAttempts>;
126
+ readonly onOutcome?: RelayOutcomeSink;
127
+ }) => Effect.Effect<void, import("@entifix/core").StoreFailure, ShutdownRegistryTag | MongoDatabaseTag | EventBusTag | OutboxMaxAttempts>;
89
128
  /**
90
129
  * One pass over every organization's outbox in a per-organization store.
91
130
  *
@@ -100,6 +139,7 @@ export declare const startOutboxRelay: (options?: {
100
139
  export declare const sweepTenantOutboxes: (directory: TenantDirectory<Db>, bus: EventBus, options: {
101
140
  readonly maxAttempts: number;
102
141
  readonly onStats?: OutboxStatsSink;
142
+ readonly onOutcome?: RelayOutcomeSink;
103
143
  readonly ensured?: Set<string>;
104
144
  }) => Effect.Effect<void, never, never>;
105
145
  /**
@@ -112,15 +152,16 @@ export declare const sweepTenantOutboxes: (directory: TenantDirectory<Db>, bus:
112
152
  */
113
153
  export declare const startTenantOutboxRelay: (options?: {
114
154
  readonly onStats?: OutboxStatsSink;
155
+ readonly onOutcome?: RelayOutcomeSink;
115
156
  }) => Effect.Effect<void, never, ShutdownRegistryTag | TenantDirectoryTag | EventBusTag | OutboxMaxAttempts>;
116
157
  /**
117
158
  * Drain one database's outbox now, for a write path that has just committed.
118
159
  *
119
160
  * The fast path the sweep is the fallback for: an event drained here leaves as
120
161
  * soon as its transaction commits, rather than up to {@link SWEEP_INTERVAL}
121
- * later. Every failure is logged and swallowed — the entry is still in the
122
- * outbox, and the sweep will carry it.
162
+ * later. Every failure is reported as `drainFailed` and swallowed — the entry
163
+ * is still in the outbox, and the sweep will carry it.
123
164
  */
124
- export declare const drainOutboxNow: (db: Db) => Effect.Effect<void, never, EventBusTag | OutboxMaxAttempts>;
165
+ export declare const drainOutboxNow: (db: Db, onOutcome?: RelayOutcomeSink) => Effect.Effect<void, never, EventBusTag | OutboxMaxAttempts>;
125
166
  export {};
126
167
  //# sourceMappingURL=relay.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"relay.d.ts","sourceRoot":"","sources":["../../src/outbox/relay.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,KAAK,eAAe,EACpB,kBAAkB,EACnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,KAAK,QAAQ,EACb,WAAW,EACX,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAiB,MAAM,QAAQ,CAAC;AAClE,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAElC,OAAO,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAIvE,yEAAyE;AACzE,eAAO,MAAM,cAAc,mBAAuB,CAAC;AACnD,8CAA8C;AAC9C,eAAO,MAAM,KAAK,MAAM,CAAC;;AAEzB;;;;;;;;;;;;;GAaG;AACH,qBAAa,iBAAkB,SAAQ,sBAGf;CAAG;AAE3B,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,WAAW,GACtB,QAAQ,iBAAiB,EACzB,KAAK,QAAQ,EACb,SAAS,YAAY,2EAkCnB,CAAC;AAEL;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,CAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,WAAW,KACf,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;AAEzB;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,WAAW,GACtB,QAAQ,iBAAiB,EACzB,KAAK,QAAQ,EACb,SAAS,YAAY,GAAG;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAA;CAAE,sCAe7D,CAAC;AAEJ;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAAI,UAAU;IACzC,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC;CACpC,4IAgBG,CAAC;AAyBL;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,GAC9B,WAAW,eAAe,CAAC,EAAE,CAAC,EAC9B,KAAK,QAAQ,EACb,SAAS;IACP,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAChC,sCAgCA,CAAC;AAEJ;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,GAAI,UAAU;IAC/C,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC;CACpC,2GAcG,CAAC;AAEL;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,IAAI,EAAE,gEAiBlC,CAAC"}
1
+ {"version":3,"file":"relay.d.ts","sourceRoot":"","sources":["../../src/outbox/relay.ts"],"names":[],"mappings":"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"}
@@ -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. */ export const SWEEP_INTERVAL = Duration.seconds(15);
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.message, quarantine);
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* Effect.logError('outbox entry quarantined').pipe(Effect.annotateLogs({
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
- lastError: outcome.failure.message
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 logged. This runs inside `Effect.forever`, and an
74
- * error that escapes ends the daemon silently — a relay that stopped is
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((error)=>Effect.logError('outbox sweep failed').pipe(Effect.annotateLogs({
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
- error: String(error)
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((error)=>Effect.logError('draining a tenant outbox failed').pipe(Effect.annotateLogs({
186
+ }).pipe(Effect.catch((failure)=>report({
187
+ _tag: 'sweepFailed',
155
188
  database,
156
- error: String(error)
157
- }))));
189
+ failure
190
+ })));
158
191
  }
159
- }).pipe(Effect.catch((error)=>Effect.logError('tenant outbox sweep failed').pipe(Effect.annotateLogs({
160
- error: String(error)
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 logged and swallowed — the entry is still in the
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((error)=>Effect.logWarning('inline outbox drain failed').pipe(Effect.annotateLogs({
235
+ }).pipe(Effect.catch((failure)=>onOutcome({
236
+ _tag: 'drainFailed',
196
237
  database: db.databaseName,
197
- error: String(error)
198
- }))));
238
+ failure
239
+ })));
199
240
 
200
241
  //# sourceMappingURL=relay.js.map
@@ -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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"}
@@ -1,4 +1,4 @@
1
- import { type DomainEvent, EntifixConnError } from '@entifix/core';
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, EntifixConnError, never>;
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, EntifixConnError, never>;
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,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"}
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"}